From 434e54ca1fac75eecb59746594baeb0e3de2694d Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 30 Oct 2026 13:44:58 -0300 Subject: [PATCH 1/2] fix: keep tabs and offer Resume when a Claude session is killed, reap its leftovers (INC-422) --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Resources/Localizable.xcstrings | 136 +++++++++++ Sources/AgentInterruptedBanner.swift | 191 +++++++++++++++ Sources/AppDelegate.swift | 1 + Sources/CommandPaletteView+Commands.swift | 6 +- Sources/GhosttyApp.swift | 4 + Sources/GhosttySurfaceScrollView.swift | 47 ++++ .../GhosttyTerminalView+SwiftUIWrapper.swift | 3 + Sources/LeftoverProcessReaper.swift | 229 ++++++++++++++++++ Sources/Panels/PanelContentView.swift | 4 + Sources/Panels/TerminalPanelView.swift | 8 + Sources/TabManager.swift | 26 ++ Sources/Workspace+Bonsplit.swift | 3 + Sources/Workspace+SidebarTelemetry.swift | 12 + Sources/Workspace.swift | 3 + Sources/WorkspaceContentView.swift | 4 + 16 files changed, 680 insertions(+), 5 deletions(-) create mode 100644 Sources/AgentInterruptedBanner.swift create mode 100644 Sources/LeftoverProcessReaper.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index de4c2a775..5ce949362 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ A2E500000000000000000002 /* AgentResumeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E500000000000000000001 /* AgentResumeCommand.swift */; }; A2E500000000000000000003 /* AgentResumeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E500000000000000000001 /* AgentResumeCommand.swift */; }; A2E500000000000000000005 /* AgentResumeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E500000000000000000004 /* AgentResumeStore.swift */; }; + A42200000000000000000004 /* AgentInterruptedBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A42200000000000000000003 /* AgentInterruptedBanner.swift */; }; + A42200000000000000000002 /* LeftoverProcessReaper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A42200000000000000000001 /* LeftoverProcessReaper.swift */; }; TTAI0001A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */; }; 7501A0000000000000000003 /* GhosttyApp+ProgramStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000004 /* GhosttyApp+ProgramStatus.swift */; }; 7501A0000000000000000001 /* ProgramStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000002 /* ProgramStatus.swift */; }; @@ -461,6 +463,8 @@ AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AgentActivityState.swift"; sourceTree = ""; }; A2E500000000000000000001 /* AgentResumeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentResumeCommand.swift; sourceTree = ""; }; A2E500000000000000000004 /* AgentResumeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentResumeStore.swift; sourceTree = ""; }; + A42200000000000000000003 /* AgentInterruptedBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentInterruptedBanner.swift; sourceTree = ""; }; + A42200000000000000000001 /* LeftoverProcessReaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftoverProcessReaper.swift; sourceTree = ""; }; TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalTabAgentIcon.swift"; sourceTree = ""; }; 7501A0000000000000000002 /* ProgramStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramStatus.swift; sourceTree = ""; }; 7501A0000000000000000008 /* TerminalController+SurfaceWaitProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SurfaceWaitProgram.swift"; sourceTree = ""; }; @@ -946,6 +950,8 @@ AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */, A2E500000000000000000001 /* AgentResumeCommand.swift */, A2E500000000000000000004 /* AgentResumeStore.swift */, + A42200000000000000000003 /* AgentInterruptedBanner.swift */, + A42200000000000000000001 /* LeftoverProcessReaper.swift */, TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */, 7501A0000000000000000002 /* ProgramStatus.swift */, 7501A0000000000000000008 /* TerminalController+SurfaceWaitProgram.swift */, @@ -1516,6 +1522,8 @@ AGST0001A1B2C3D4E5F60719 /* AgentActivityState.swift in Sources */, A2E500000000000000000002 /* AgentResumeCommand.swift in Sources */, A2E500000000000000000005 /* AgentResumeStore.swift in Sources */, + A42200000000000000000004 /* AgentInterruptedBanner.swift in Sources */, + A42200000000000000000002 /* LeftoverProcessReaper.swift in Sources */, TTAI0001A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift in Sources */, 7501A0000000000000000001 /* ProgramStatus.swift in Sources */, 7501A0000000000000000007 /* TerminalController+SurfaceWaitProgram.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 81e70d0a7..645b73d9d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -17510,6 +17510,142 @@ } } } + }, + "agentInterrupted.shellAlive.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude stopped unexpectedly." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude が予期せず停止しました。" + } + } + } + }, + "agentInterrupted.paneDead.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This pane's processes were killed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このペインのプロセスが強制終了されました。" + } + } + } + }, + "agentInterrupted.resume": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再開" + } + } + } + }, + "agentInterrupted.resume.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume the Claude session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude セッションを再開" + } + } + } + }, + "agentInterrupted.dismiss": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる" + } + } + } + }, + "agentInterrupted.dismiss.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss this notice" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この通知を閉じる" + } + } + } + }, + "agentInterrupted.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインを閉じる" + } + } + } + }, + "agentInterrupted.close.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close this pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このペインを閉じる" + } + } + } } }, "version": "1.0" diff --git a/Sources/AgentInterruptedBanner.swift b/Sources/AgentInterruptedBanner.swift new file mode 100644 index 000000000..5d492fc12 --- /dev/null +++ b/Sources/AgentInterruptedBanner.swift @@ -0,0 +1,191 @@ +import AppKit +import Bonsplit +import Foundation +import SwiftUI + +/// Why a panel shows the interrupted-agent banner. +enum InterruptedAgentState: Equatable, Sendable { + /// The agent died without a clean SessionEnd; the shell in the pane is still running. + case shellAlive + /// Every process in the pane was killed (the pane's root exited) while an agent ran. + case paneDead +} + +enum AgentInterruptedBannerAction { + case resume + case dismiss +} + +/// Turns a tracked agent's exit into an interrupted-agent banner when the exit was not clean. +@MainActor +enum AgentInterruptionTracker { + static func install() { + LeftoverProcessReaper.shared.onAgentExit = { pid in handleAgentExit(pid: pid) } + } + + /// A clean SessionEnd hook clears the panel's agent presence before the process exits, so + /// presence that still carries `pid` means the agent was killed. + static func handleAgentExit(pid: pid_t) { + var matched = false + for workspace in allWorkspaces() { + var panelIds = workspace.panelAgentPresence.filter { $0.value.sessionKey?.pid == pid }.map(\.key) + // SessionEnd clears the workspace's Claude pid, so a pid still registered there + // died without it. Presence only learns the pid on a tool call, so fall back to the + // one Claude panel that has no pid yet. Program-source presence (OSC 7501) carries + // no session key, so the panel's provider comes from its resume target. + if panelIds.isEmpty, workspace.agentPIDs["claude_code"] == pid { + workspace.agentPIDs.removeValue(forKey: "claude_code") + let candidates = workspace.panelAgentPresence.filter { + $0.value.sessionKey?.pid == nil + && ["claude-code", "claude"].contains(AgentResumeStore.shared.target(panelId: $0.key)?.provider ?? "") + }.map(\.key) + if candidates.count == 1 { panelIds = candidates } + } + for panelId in panelIds { + guard let presence = workspace.panelAgentPresence[panelId] else { continue } + matched = true + dilog("agent", "exit pid=\(pid) panel=\(panelId.uuidString.prefix(8)) clean=false") + if workspace.interruptedAgentPanels[panelId] == nil, workspace.agentResumeLine(panelId: panelId) != nil { + workspace.interruptedAgentPanels[panelId] = .shellAlive + } + workspace.clearPanelAgentState(panelId: panelId, force: true) + let sessionId = presence.sessionKey?.sessionId + AgentSupervisionRegistry.shared.finishRunningHelpers(workspaceId: workspace.id) { record in + record.surfaceId == panelId || (sessionId != nil && record.session == sessionId) + } + } + } + if !matched { + dilog("agent", "exit pid=\(pid) panel=none clean=true") + } + } + + private static func allWorkspaces() -> [Workspace] { + var managers: [TabManager] = [] + if let app = AppDelegate.shared { + managers.append(contentsOf: app.mainWindowContexts.values.map(\.tabManager)) + if let manager = app.tabManager { managers.append(manager) } + } + if let manager = TerminalController.shared.tabManager { managers.append(manager) } + var seen = Set() + return managers + .filter { seen.insert(ObjectIdentifier($0)).inserted } + .flatMap(\.tabs) + } +} + +extension Workspace { + /// The shell line that resumes the panel's last agent session, or nil when there is no + /// session to resume (unsupported provider, unsafe id, or a Claude session that never got + /// a first prompt). + func agentResumeLine(panelId: UUID, paneDirectory: String? = nil) -> String? { + guard let target = AgentResumeStore.shared.target(panelId: panelId), + let line = AgentResumeCommand.line( + for: target, paneDirectory: paneDirectory ?? panelDirectories[panelId] + ), + !AgentResumeCommand.claudeTranscriptIsMissing(for: target) else { return nil } + return line + } + + func handleInterruptedAgentAction(_ action: AgentInterruptedBannerAction, panelId: UUID) { + guard let state = interruptedAgentPanels[panelId] else { return } + switch (action, state) { + case (.resume, .shellAlive): + guard let terminalPanel = terminalPanel(for: panelId), + let line = agentResumeLine(panelId: panelId) else { + NSSound.beep() + return + } + interruptedAgentPanels.removeValue(forKey: panelId) + terminalPanel.sendInput(line + "\r") + case (.resume, .paneDead): + // The new tab starts in the dead pane's directory, so the line is built for it. + let directory = panelDirectories[panelId] ?? terminalPanel(for: panelId)?.requestedWorkingDirectory + guard let paneId = paneId(forPanelId: panelId), + let line = agentResumeLine(panelId: panelId, paneDirectory: directory), + newTerminalSurface(inPane: paneId, focus: true, workingDirectory: directory, initialInput: line + "\n") != nil + else { + NSSound.beep() + return + } + closeDeadPanel(panelId) + case (.dismiss, .shellAlive): + interruptedAgentPanels.removeValue(forKey: panelId) + case (.dismiss, .paneDead): + closeDeadPanel(panelId) + } + } + + private func closeDeadPanel(_ panelId: UUID) { + if let owningTabManager { + owningTabManager.closePanelAfterChildExitedUnconditionally(tab: self, surfaceId: panelId) + } else { + interruptedAgentPanels.removeValue(forKey: panelId) + _ = closePanel(panelId, force: true) + } + } +} + +/// Top-of-pane bar shown over a terminal whose agent was interrupted. Mounted from +/// `GhosttySurfaceScrollView` (the portal layer), never from a SwiftUI panel container. +struct AgentInterruptedBanner: View { + let state: InterruptedAgentState + let onAction: (AgentInterruptedBannerAction) -> Void + + private var message: String { + switch state { + case .shellAlive: + return String(localized: "agentInterrupted.shellAlive.message", defaultValue: "Claude stopped unexpectedly.") + case .paneDead: + return String(localized: "agentInterrupted.paneDead.message", defaultValue: "This pane's processes were killed.") + } + } + + private var dismissTitle: String { + switch state { + case .shellAlive: + return String(localized: "agentInterrupted.dismiss", defaultValue: "Dismiss") + case .paneDead: + return String(localized: "agentInterrupted.close", defaultValue: "Close") + } + } + + private var dismissAccessibilityLabel: String { + switch state { + case .shellAlive: + return String(localized: "agentInterrupted.dismiss.accessibility", defaultValue: "Dismiss this notice") + case .paneDead: + return String(localized: "agentInterrupted.close.accessibility", defaultValue: "Close this pane") + } + } + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + .accessibilityHidden(true) + Text(message) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + Button(String(localized: "agentInterrupted.resume", defaultValue: "Resume")) { + onAction(.resume) + } + .accessibilityLabel(String( + localized: "agentInterrupted.resume.accessibility", defaultValue: "Resume the Claude session" + )) + Button(dismissTitle) { + onAction(.dismiss) + } + .accessibilityLabel(dismissAccessibilityLabel) + } + .controlSize(.small) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(.bar) + .overlay(alignment: .bottom) { Divider() } + .accessibilityElement(children: .contain) + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d1c2964b1..30c4c0003 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -377,6 +377,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) Self.removeLegacyPersistedWindowGeometry() + AgentInterruptionTracker.install() DistributedNotificationCenter.default().addObserver( self, diff --git a/Sources/CommandPaletteView+Commands.swift b/Sources/CommandPaletteView+Commands.swift index ecd3e15bb..995cf9e18 100644 --- a/Sources/CommandPaletteView+Commands.swift +++ b/Sources/CommandPaletteView+Commands.swift @@ -709,11 +709,7 @@ extension CommandPaletteView { let terminalPanel = context.panel as? TerminalPanel, context.workspace.panelAgentPresence[context.panelId] == nil, context.workspace.panelShellActivityStates[context.panelId] == .promptIdle, - let target = AgentResumeStore.shared.target(panelId: context.panelId), - let line = AgentResumeCommand.line( - for: target, paneDirectory: context.workspace.panelDirectories[context.panelId] - ), - !AgentResumeCommand.claudeTranscriptIsMissing(for: target) else { + let line = context.workspace.agentResumeLine(panelId: context.panelId) else { NSSound.beep() return } diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index 0fcce5d73..8218d8567 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -772,6 +772,10 @@ class GhosttyApp { let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), workspace.panels[callbackSurfaceId] != nil { + // Ghostty closes the surface itself right after SHOW_CHILD_EXITED (and on + // any key press in a dead surface); a pane kept for its interrupted agent + // closes only from its banner. + if workspace.interruptedAgentPanels[callbackSurfaceId] == .paneDead { return } if needsConfirmClose { manager.closeRuntimeSurfaceWithConfirmation( tabId: callbackTabId, diff --git a/Sources/GhosttySurfaceScrollView.swift b/Sources/GhosttySurfaceScrollView.swift index f215b92fc..b4b28810f 100644 --- a/Sources/GhosttySurfaceScrollView.swift +++ b/Sources/GhosttySurfaceScrollView.swift @@ -156,6 +156,8 @@ final class GhosttySurfaceScrollView: NSView { private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem? private var lastSearchOverlayStateID: ObjectIdentifier? private var searchOverlayMutationGeneration: UInt64 = 0 + private var agentInterruptedBannerHostingView: NSHostingView? + private var agentInterruptedBannerAction: ((AgentInterruptedBannerAction) -> Void)? #if DEBUG // Test-only override for the mounted-search-field-focus path's window.isKeyWindow // guard. Headless XCTest hosts frequently cannot make a plain NSWindow genuinely @@ -711,6 +713,7 @@ final class GhosttySurfaceScrollView: NSView { if let overlay = searchOverlayHostingView { _ = setFrameIfNeeded(overlay, to: bounds) } + layoutAgentInterruptedBanner() // NSScrollView can defer clip-view/content-size updates until its own layout pass, // which makes interactive width changes arrive a queue turn late on Sequoia. scrollView.layoutSubtreeIfNeeded() @@ -1324,6 +1327,50 @@ final class GhosttySurfaceScrollView: NSView { } } + /// Shows or removes the interrupted-agent bar across the top of the pane. Mounted here, in + /// the portal layer, for the same reason as the find overlay. + func setAgentInterruptedBanner( + state: InterruptedAgentState?, + onAction: ((AgentInterruptedBannerAction) -> Void)? + ) { + agentInterruptedBannerAction = onAction + guard let state else { + agentInterruptedBannerHostingView?.removeFromSuperview() + agentInterruptedBannerHostingView = nil + return + } + if let banner = agentInterruptedBannerHostingView { + if banner.rootView.state != state { + banner.rootView = makeAgentInterruptedBanner(state: state) + } + } else { + let banner = NSHostingView(rootView: makeAgentInterruptedBanner(state: state)) + // No safe area inside a pane (issue #307). + banner.safeAreaRegions = [] + banner.autoresizingMask = [.width, .minYMargin] + agentInterruptedBannerHostingView = banner + } + if let banner = agentInterruptedBannerHostingView, banner.superview !== self { + addSubview(banner, positioned: .above, relativeTo: nil) + } + layoutAgentInterruptedBanner() + } + + private func makeAgentInterruptedBanner(state: InterruptedAgentState) -> AgentInterruptedBanner { + AgentInterruptedBanner(state: state) { [weak self] action in + self?.agentInterruptedBannerAction?(action) + } + } + + private func layoutAgentInterruptedBanner() { + guard let banner = agentInterruptedBannerHostingView else { return } + let height = min(banner.fittingSize.height, bounds.height) + _ = setFrameIfNeeded( + banner, + to: NSRect(x: bounds.minX, y: bounds.maxY - height, width: bounds.width, height: height) + ) + } + func syncKeyStateIndicator(text: String?) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in diff --git a/Sources/GhosttyTerminalView+SwiftUIWrapper.swift b/Sources/GhosttyTerminalView+SwiftUIWrapper.swift index d24d949a3..207fea47b 100644 --- a/Sources/GhosttyTerminalView+SwiftUIWrapper.swift +++ b/Sources/GhosttyTerminalView+SwiftUIWrapper.swift @@ -25,6 +25,8 @@ struct GhosttyTerminalView: NSViewRepresentable { var inactiveOverlayColor: NSColor = .clear var inactiveOverlayOpacity: Double = 0 var searchState: TerminalSurface.SearchState? = nil + var interruptedAgentState: InterruptedAgentState? = nil + var onInterruptedAgentAction: ((AgentInterruptedBannerAction) -> Void)? = nil var reattachToken: UInt64 = 0 var onFocus: ((UUID) -> Void)? = nil var onTriggerFlash: (() -> Void)? = nil @@ -228,6 +230,7 @@ struct GhosttyTerminalView: NSViewRepresentable { ) hostedView.setNotificationRing(visible: showsUnreadNotificationRing) hostedView.setSearchOverlay(searchState: searchState) + hostedView.setAgentInterruptedBanner(state: interruptedAgentState, onAction: onInterruptedAgentAction) hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText) } let portalExpectedSurfaceId = terminalSurface.id diff --git a/Sources/LeftoverProcessReaper.swift b/Sources/LeftoverProcessReaper.swift new file mode 100644 index 000000000..899d3378f --- /dev/null +++ b/Sources/LeftoverProcessReaper.swift @@ -0,0 +1,229 @@ +import Bonsplit +import Darwin +import Foundation + +/// Terminates the processes a Claude agent launched and left behind when it exits. +/// +/// macOS charges each process to a "responsible process", and a Dock force-quit of an app +/// also kills its responsible process. Claude Code disclaims responsibility for itself, so +/// everything it launches (MCP servers that register as background apps, debug binaries run +/// directly) is charged to the `claude` pid; force-quitting one of those leftovers kills the +/// agent. Reaping them when their agent exits keeps them from piling up in the Dock. +/// +/// When the owner dies, macOS re-points each leftover's responsible pid at the leftover +/// itself, so ownership is snapshotted while the owner is alive and the reap works from the +/// last snapshot. Only agent pids are tracked: daemons a user starts from a plain shell +/// (colima and the like) are deliberate and stay alive. +final class LeftoverProcessReaper { + static let shared = LeftoverProcessReaper() + + /// Main-thread notification that a tracked agent exited, delivered after a short grace so a + /// clean SessionEnd hook has had time to clear the panel's agent presence. + var onAgentExit: (@MainActor (pid_t) -> Void)? + + private struct Leftover { + let startTime: UInt64 + let name: String + } + + private struct Owner { + let startTime: UInt64 + let exitSource: DispatchSourceProcess + var leftovers: [pid_t: Leftover] = [:] + /// Set at exit, so a snapshot during the reap grace never sees the owner as live (a + /// zombie keeps its start time) and wipes the leftovers its exit re-pointed. + var exited = false + } + + private let queue = DispatchQueue(label: "com.darkroom.programa.leftover-reaper", qos: .utility) + /// Mirror of `owners.keys` readable from any thread, so the per-hook-event `trackAgent` + /// call from main skips the queue hop for an owner that is already tracked. + private let trackedLock = NSLock() + private var trackedPids = Set() + private var owners: [pid_t: Owner] = [:] + private var snapshotTimer: DispatchSourceTimer? + + // SHORTCUT: ownership snapshots every 10 s. ceiling: anything an agent launches in its + // last ~10 s of life is missed and survives. upgrade: if leftovers are still reported, + // follow forks with kqueue NOTE_FORK on each owner instead of polling. + private static let snapshotInterval: DispatchTimeInterval = .seconds(10) + private static let initialSnapshotDelay: DispatchTimeInterval = .seconds(3) + private static let reapGrace: DispatchTimeInterval = .seconds(2) + private static let killGrace: DispatchTimeInterval = .seconds(3) + private static let exitNotifyGrace: DispatchTimeInterval = .milliseconds(500) + + private typealias ResponsibleLookup = @convention(c) (pid_t) -> pid_t + /// `responsibility_get_pid_responsible_for_pid` is private libsystem API; when it is + /// missing the reaper does nothing. + private static let responsibleLookup: ResponsibleLookup? = { + guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "responsibility_get_pid_responsible_for_pid") else { + return nil + } + return unsafeBitCast(symbol, to: ResponsibleLookup.self) + }() + + private init() {} + + /// Registers an agent pid as an owner of leftovers. Idempotent and cheap: called on every + /// hook event that carries the agent's pid. + func trackAgent(pid: pid_t) { + guard pid > 1, pid != getpid(), Self.responsibleLookup != nil else { return } + trackedLock.lock() + let inserted = trackedPids.insert(pid).inserted + trackedLock.unlock() + guard inserted else { return } + queue.async { [self] in startTracking(pid: pid) } + } + + /// Re-reads which live processes `owner` is responsible for. + func refreshSnapshot(owner: pid_t) { + queue.async { [self] in snapshot(owners: [owner]) } + } + + // MARK: - Queue-confined + + private func startTracking(pid: pid_t) { + guard owners[pid] == nil else { return } + guard let info = Self.processInfo(pid: pid) else { + untrack(pid) + return + } + let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: queue) + source.setEventHandler { [weak self] in self?.ownerExited(pid: pid, startTime: info.startTime) } + owners[pid] = Owner(startTime: info.startTime, exitSource: source) + source.resume() + // A process source never fires for a pid that exited before it was armed. + if kill(pid, 0) == -1, errno == ESRCH { + ownerExited(pid: pid, startTime: info.startTime) + return + } + ensureSnapshotTimer() + // MCP servers start a moment after the agent reports in. + queue.asyncAfter(deadline: .now() + Self.initialSnapshotDelay) { [weak self] in + self?.snapshot(owners: [pid]) + } + } + + private func ensureSnapshotTimer() { + guard snapshotTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + Self.snapshotInterval, repeating: Self.snapshotInterval, leeway: .seconds(2)) + timer.setEventHandler { [weak self] in + guard let self else { return } + self.snapshot(owners: Set(self.owners.keys)) + } + snapshotTimer = timer + timer.resume() + } + + private func snapshot(owners requested: Set) { + guard let lookup = Self.responsibleLookup else { return } + let live = requested.filter { pid in + guard let owner = owners[pid], !owner.exited else { return false } + return Self.processInfo(pid: pid)?.startTime == owner.startTime + } + guard !live.isEmpty else { return } + let selfPid = getpid() + let uid = getuid() + var found: [pid_t: [pid_t: Leftover]] = Dictionary(uniqueKeysWithValues: live.map { ($0, [:]) }) + for pid in Self.allPids() where pid > 1 && pid != selfPid && owners[pid] == nil { + let responsible = lookup(pid) + guard responsible > 0, live.contains(responsible), + let info = Self.processInfo(pid: pid), info.uid == uid else { continue } + found[responsible]?[pid] = Leftover(startTime: info.startTime, name: Self.processName(pid: pid)) + } + for (owner, leftovers) in found { + owners[owner]?.leftovers = leftovers + } + } + + private func ownerExited(pid: pid_t, startTime: UInt64) { + guard let owner = owners[pid], owner.startTime == startTime, !owner.exited else { return } + owners[pid]?.exited = true + owner.exitSource.cancel() + DispatchQueue.main.asyncAfter(deadline: .now() + Self.exitNotifyGrace) { [weak self] in + MainActor.assumeIsolated { self?.onAgentExit?(pid) } + } + queue.asyncAfter(deadline: .now() + Self.reapGrace) { [weak self] in + self?.reap(owner: pid) + } + } + + private func reap(owner pid: pid_t) { + guard let owner = owners[pid] else { return } + let selfPid = getpid() + var targets: [(pid: pid_t, leftover: Leftover)] = [] + var skippedNohup = 0 + for (leftoverPid, leftover) in owner.leftovers where leftoverPid != selfPid { + guard let info = Self.processInfo(pid: leftoverPid), info.startTime == leftover.startTime else { continue } + if info.ignoresSIGHUP { + skippedNohup += 1 + continue + } + if kill(leftoverPid, SIGTERM) == 0 { + targets.append((leftoverPid, leftover)) + } + } + let names = targets.prefix(10).map(\.leftover.name).joined(separator: ",") + dilog("reaper", "owner=\(pid) exited reaped=\(targets.count) skippedNohup=\(skippedNohup) names=\(names)") + owners.removeValue(forKey: pid) + untrack(pid) + if owners.isEmpty { + snapshotTimer?.cancel() + snapshotTimer = nil + } + guard !targets.isEmpty else { return } + queue.asyncAfter(deadline: .now() + Self.killGrace) { + for target in targets where Self.processInfo(pid: target.pid)?.startTime == target.leftover.startTime { + kill(target.pid, SIGKILL) + } + } + } + + private func untrack(_ pid: pid_t) { + trackedLock.lock() + trackedPids.remove(pid) + trackedLock.unlock() + } + + // MARK: - Kernel lookups + + private struct ProcessSnapshot { + let startTime: UInt64 + let uid: uid_t + let ignoresSIGHUP: Bool + } + + private static func processInfo(pid: pid_t) -> ProcessSnapshot? { + var info = kinfo_proc() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0, + size > 0, + info.kp_proc.p_pid == pid else { return nil } + let start = info.kp_proc.p_un.__p_starttime + return ProcessSnapshot( + startTime: UInt64(start.tv_sec) * 1_000_000 + UInt64(start.tv_usec), + uid: info.kp_eproc.e_ucred.cr_uid, + ignoresSIGHUP: info.kp_proc.p_sigignore & (sigset_t(1) << sigset_t(SIGHUP - 1)) != 0 + ) + } + + private static func allPids() -> [pid_t] { + let estimate = proc_listallpids(nil, 0) + guard estimate > 0 else { return [] } + // Headroom for processes spawned between the sizing call and the read. + var pids = [pid_t](repeating: 0, count: Int(estimate) + 64) + let count = pids.withUnsafeMutableBytes { buffer in + proc_listallpids(buffer.baseAddress, Int32(buffer.count)) + } + guard count > 0 else { return [] } + return Array(pids.prefix(Int(count))) + } + + private static func processName(pid: pid_t) -> String { + var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN) * 2 + 1) + guard proc_name(pid, &buffer, UInt32(buffer.count)) > 0 else { return "?" } + return String(cString: buffer) + } +} diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 4b83cf54b..78d8909d6 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -12,6 +12,8 @@ struct PanelContentView: View { let isSplit: Bool let appearance: PanelAppearance let hasUnreadNotification: Bool + let interruptedAgentState: InterruptedAgentState? + let onInterruptedAgentAction: (AgentInterruptedBannerAction) -> Void let onFocus: () -> Void let onRequestPanelFocus: () -> Void let onTriggerFlash: () -> Void @@ -29,6 +31,8 @@ struct PanelContentView: View { isSplit: isSplit, appearance: appearance, hasUnreadNotification: hasUnreadNotification, + interruptedAgentState: interruptedAgentState, + onInterruptedAgentAction: onInterruptedAgentAction, onFocus: onFocus, onTriggerFlash: onTriggerFlash ) diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 84b5df71a..eb0772b75 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -15,6 +15,8 @@ struct TerminalPanelView: View { let isSplit: Bool let appearance: PanelAppearance let hasUnreadNotification: Bool + let interruptedAgentState: InterruptedAgentState? + let onInterruptedAgentAction: (AgentInterruptedBannerAction) -> Void let onFocus: () -> Void let onTriggerFlash: () -> Void @@ -28,6 +30,8 @@ struct TerminalPanelView: View { isSplit: Bool, appearance: PanelAppearance, hasUnreadNotification: Bool, + interruptedAgentState: InterruptedAgentState? = nil, + onInterruptedAgentAction: @escaping (AgentInterruptedBannerAction) -> Void = { _ in }, onFocus: @escaping () -> Void, onTriggerFlash: @escaping () -> Void ) { @@ -40,6 +44,8 @@ struct TerminalPanelView: View { self.isSplit = isSplit self.appearance = appearance self.hasUnreadNotification = hasUnreadNotification + self.interruptedAgentState = interruptedAgentState + self.onInterruptedAgentAction = onInterruptedAgentAction self.onFocus = onFocus self.onTriggerFlash = onTriggerFlash } @@ -58,6 +64,8 @@ struct TerminalPanelView: View { inactiveOverlayColor: appearance.unfocusedOverlayNSColor, inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, searchState: panel.searchState, + interruptedAgentState: interruptedAgentState, + onInterruptedAgentAction: onInterruptedAgentAction, reattachToken: panel.viewReattachToken, onFocus: { _ in onFocus() }, onTriggerFlash: onTriggerFlash diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index aa2f3efc7..fa5a36820 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2238,6 +2238,32 @@ class TabManager: ObservableObject { ) #endif + let rootPid = tab.terminalPanel(for: surfaceId)?.surface.surface + .map { ghostty_surface_child_pid($0) } ?? -1 + // A pane whose agent was still running (or already died abnormally) is kept with a + // Resume banner instead of closing: its processes were killed from outside, and + // closing would lose the session the user was in. + let keep = tab.interruptedAgentPanels[surfaceId] != nil || tab.panelAgentPresence[surfaceId] != nil + let resumable = AgentResumeStore.shared.target(panelId: surfaceId).map { + AgentResumeCommand.canResume($0) && !AgentResumeCommand.claudeTranscriptIsMissing(for: $0) + } == true + dilog("surface", "childExited panel=\(surfaceId.uuidString.prefix(8)) kept=\(keep && resumable) rootPid=\(rootPid)") + if keep, resumable { + tab.interruptedAgentPanels[surfaceId] = .paneDead + tab.clearPanelAgentState(panelId: surfaceId, force: true) + return + } + + closePanelAfterChildExitedUnconditionally(tab: tab, surfaceId: surfaceId) + } + + /// The close half of `closePanelAfterChildExited`, also used by the interrupted-agent + /// banner's Close and Resume actions for a pane whose processes are gone. + func closePanelAfterChildExitedUnconditionally(tab: Workspace, surfaceId: UUID) { + let tabId = tab.id + if tab.interruptedAgentPanels[surfaceId] != nil { + tab.interruptedAgentPanels.removeValue(forKey: surfaceId) + } // Child-exit on the last panel should collapse the workspace, matching explicit close // semantics (and close the window when it was the last workspace). if tab.panels.count <= 1 { diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift index 7fe2dbb49..5c85b8f2d 100644 --- a/Sources/Workspace+Bonsplit.swift +++ b/Sources/Workspace+Bonsplit.swift @@ -715,6 +715,9 @@ extension Workspace: @preconcurrency BonsplitDelegate { AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) clearPanelAgentState(panelId: panelId, force: true) AgentResumeStore.shared.forget(panelId: panelId) + if interruptedAgentPanels[panelId] != nil { + interruptedAgentPanels.removeValue(forKey: panelId) + } } if progressSourcePanelId == panelId { progress = nil diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift index 57ceb3b4e..5163537c0 100644 --- a/Sources/Workspace+SidebarTelemetry.swift +++ b/Sources/Workspace+SidebarTelemetry.swift @@ -257,6 +257,14 @@ extension Workspace { transcriptPath: transcriptPath ) } + if source == .hooks, let sessionKey { + if interruptedAgentPanels[panelId] != nil { + interruptedAgentPanels.removeValue(forKey: panelId) + } + if let pid = sessionKey.pid, sessionKey.provider == "claude-code" || sessionKey.provider == "claude" { + LeftoverProcessReaper.shared.trackAgent(pid: pid) + } + } if source != .program, let current, current.source == .program, current.state != .idle || state == .idle { @@ -826,6 +834,10 @@ extension Workspace { return false } agentPIDs[key] = pid + // SessionStart reports Claude's pid here; per-surface presence only gets it on a tool call. + if key == "claude_code" { + LeftoverProcessReaper.shared.trackAgent(pid: pid) + } return true } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 6440debf5..fd681dac7 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -158,6 +158,9 @@ final class Workspace: Identifiable, ObservableObject { var panelAgentStateSources: [UUID: AgentStateSource] { panelAgentPresence.mapValues(\.source) } + /// Panels whose agent died without a clean SessionEnd; each shows the interrupted-agent + /// banner. See AgentInterruptedBanner.swift. + @Published var interruptedAgentPanels: [UUID: InterruptedAgentState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] var agentListeningPorts: [Int] = [] @Published var listeningPorts: [Int] = [] diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index a3ed11b14..fc629667a 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -101,6 +101,10 @@ struct WorkspaceContentView: View { isSplit: isSplit, appearance: appearance, hasUnreadNotification: showsNotificationRing, + interruptedAgentState: workspace.interruptedAgentPanels[panel.id], + onInterruptedAgentAction: { action in + workspace.handleInterruptedAgentAction(action, panelId: panel.id) + }, onFocus: { // Keep bonsplit focus in sync with the AppKit first responder for the // active workspace. This prevents divergence between the blue focused-tab From cbe83504af2fbcc221888ed86fbbf1d122bebd00 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 30 Oct 2026 13:50:26 -0300 Subject: [PATCH 2/2] fix: let exit close a pane whose Claude session already stopped (INC-422) --- Sources/TabManager.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index fa5a36820..d6515d199 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2240,10 +2240,11 @@ class TabManager: ObservableObject { let rootPid = tab.terminalPanel(for: surfaceId)?.surface.surface .map { ghostty_surface_child_pid($0) } ?? -1 - // A pane whose agent was still running (or already died abnormally) is kept with a - // Resume banner instead of closing: its processes were killed from outside, and - // closing would lose the session the user was in. - let keep = tab.interruptedAgentPanels[surfaceId] != nil || tab.panelAgentPresence[surfaceId] != nil + // A pane whose agent was still running is kept with a Resume banner instead of + // closing: its processes were killed from outside, and closing would lose the + // session the user was in. A `.shellAlive` pane is not kept: its agent was already + // gone, so the shell exiting is the user's own `exit` or Ctrl-D. + let keep = tab.panelAgentPresence[surfaceId] != nil || tab.interruptedAgentPanels[surfaceId] == .paneDead let resumable = AgentResumeStore.shared.target(panelId: surfaceId).map { AgentResumeCommand.canResume($0) && !AgentResumeCommand.claudeTranscriptIsMissing(for: $0) } == true