From 623262493bc1fc48507386a221146d1474cb90f0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:47:42 -0700 Subject: [PATCH] Fix stale Claude sidebar status: add missing hooks, OSC suppression, PID sweep (#1306) * Fix stale Claude status in sidebar by adding missing hooks and OSC suppression The Claude Code integration only used 3 hooks (SessionStart, Stop, Notification), leaving gaps that caused stale sidebar status. Now uses 6 hooks: - SessionEnd: clears status when Claude exits (covers Ctrl+C where Stop doesn't fire) - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt - PreToolUse (async): clears "Needs input" when Claude resumes after permission grant Also: - Suppress OSC 9/99 desktop notifications for workspaces with active Claude hook sessions to prevent duplicates from the raw OSC path - Store Claude process PID in status entries for stale-session detection - Add 30-second sweep timer that checks agent PIDs and clears stale entries (safety net for SIGKILL/crash where no hook fires) - Update wrapper test expectations for the new hook set Fixes https://github.com/manaflow-ai/cmux/issues/1301 * Don't show "Running" status on Claude launch, only when actually working SessionStart now registers the PID for tracking and OSC suppression via set_agent_pid without setting a visible status entry. "Running" only appears when the user submits a prompt (UserPromptSubmit) or Claude starts using tools (PreToolUse). Added set_agent_pid / clear_agent_pid socket commands to decouple PID tracking from visible status entries. OSC suppression checks agentPIDs instead of statusEntries so it works during the initial idle period. * Don't restore status entries across app restarts Status entries are ephemeral runtime state tied to running processes (e.g. claude_code "Running"). Restoring them after restart shows stale status for processes that no longer exist. * Address PR review comments and remove debug logging - session-end: only clear status/PID/notifications when Stop didn't fire first - PID sweep: check errno == ESRCH instead of treating all kill(pid,0) failures as dead - Validate CMUX_CLAUDE_PID > 0 - Propagate tracked PID in pre-tool-use setClaudeStatus - OSC suppression: use tabManagerFor(tabId:) for multi-window support - clearAgentPID: resolve tab UUID before async dispatch - restoreSessionSnapshot: also clear agentPIDs alongside statusEntries - Fix AskUserQuestion surfaceId overwrite (wrong workspace notification) - Fix notification text matching for "Claude Code needs your attention" - AskUserQuestion: render option labels as bracketed inline text - Remove artificial text truncation limits - Remove temporary JSONL debug logging from all handlers * Use resolveTabIdForSidebarMutation in clearAgentPID --- CLI/cmux.swift | 301 ++++++++++++++++++++++++----- Resources/bin/claude | 10 +- Sources/GhosttyTerminalView.swift | 18 +- Sources/TabManager.swift | 51 +++++ Sources/TerminalController.swift | 61 ++++++ Sources/Workspace.swift | 23 +-- tests/test_claude_wrapper_hooks.py | 20 +- 7 files changed, 418 insertions(+), 66 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2ab83b66..8831f409 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -316,6 +316,7 @@ private struct ClaudeHookSessionRecord: Codable { var workspaceId: String var surfaceId: String var cwd: String? + var pid: Int? var lastSubtitle: String? var lastBody: String? var startedAt: TimeInterval @@ -363,6 +364,7 @@ private final class ClaudeHookSessionStore { workspaceId: String, surfaceId: String, cwd: String?, + pid: Int? = nil, lastSubtitle: String? = nil, lastBody: String? = nil ) throws { @@ -375,16 +377,22 @@ private final class ClaudeHookSessionStore { workspaceId: workspaceId, surfaceId: surfaceId, cwd: nil, + pid: nil, lastSubtitle: nil, lastBody: nil, startedAt: now, updatedAt: now ) record.workspaceId = workspaceId - record.surfaceId = surfaceId + if !surfaceId.isEmpty { + record.surfaceId = surfaceId + } if let cwd = normalizeOptional(cwd) { record.cwd = cwd } + if let pid { + record.pid = pid + } if let subtitle = normalizeOptional(lastSubtitle) { record.lastSubtitle = subtitle } @@ -8477,39 +8485,68 @@ struct CMUXCLI { workspaceId: workspaceId, client: client ) + let claudePid: Int? = { + guard let raw = ProcessInfo.processInfo.environment["CMUX_CLAUDE_PID"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + let pid = Int(raw), + pid > 0 else { + return nil + } + return pid + }() if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, - cwd: parsedInput.cwd + cwd: parsedInput.cwd, + pid: claudePid + ) + } + // Register PID for stale-session detection and OSC suppression, + // but don't set a visible status. "Running" only appears when the + // user submits a prompt (UserPromptSubmit) or Claude starts working + // (PreToolUse). + if let claudePid { + _ = try? sendV1Command( + "set_agent_pid claude_code \(claudePid) --tab=\(workspaceId)", + client: client ) } - try setClaudeStatus( - client: client, - workspaceId: workspaceId, - value: "Running", - icon: "bolt.fill", - color: "#4C8DFF" - ) print("OK") case "stop", "idle": telemetry.breadcrumb("claude-hook.stop") - let consumedSession = try? sessionStore.consume( - sessionId: parsedInput.sessionId, - workspaceId: fallbackWorkspaceId, - surfaceId: fallbackSurfaceId - ) - let workspaceId = consumedSession?.workspaceId ?? fallbackWorkspaceId - try clearClaudeStatus(client: client, workspaceId: workspaceId) + // Turn ended. Don't consume session or clear PID — Claude is still alive. + // Notification hook handles user-facing notifications; SessionEnd handles cleanup. + var workspaceId = fallbackWorkspaceId + var surfaceId = surfaceArg + if let sessionId = parsedInput.sessionId, + let mapped = try? sessionStore.lookup(sessionId: sessionId), + let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) { + workspaceId = mappedWorkspace + surfaceId = mapped.surfaceId + } - if let completion = summarizeClaudeHookStop( + // Update session with transcript summary and send completion notification. + let completion = summarizeClaudeHookStop( parsedInput: parsedInput, - sessionRecord: consumedSession - ) { - let surfaceId = try resolveSurfaceIdForClaudeHook( - consumedSession?.surfaceId ?? surfaceArg, + sessionRecord: (try? sessionStore.lookup(sessionId: parsedInput.sessionId ?? "")) + ) + if let sessionId = parsedInput.sessionId, let completion { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId ?? "", + cwd: parsedInput.cwd, + lastSubtitle: completion.subtitle, + lastBody: completion.body + ) + } + + if let completion { + let resolvedSurface = try resolveSurfaceIdForClaudeHook( + surfaceId, workspaceId: workspaceId, client: client ) @@ -8517,12 +8554,18 @@ struct CMUXCLI { let subtitle = sanitizeNotificationField(completion.subtitle) let body = sanitizeNotificationField(completion.body) let payload = "\(title)|\(subtitle)|\(body)" - let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) - print(response) - } else { - print("OK") + _ = try? sendV1Command("notify_target \(workspaceId) \(resolvedSurface) \(payload)", client: client) } + try setClaudeStatus( + client: client, + workspaceId: workspaceId, + value: "Idle", + icon: "pause.circle.fill", + color: "#8E8E93" + ) + print("OK") + case "prompt-submit": telemetry.breadcrumb("claude-hook.prompt-submit") var workspaceId = fallbackWorkspaceId @@ -8543,7 +8586,7 @@ struct CMUXCLI { case "notification", "notify": telemetry.breadcrumb("claude-hook.notification") - let summary = summarizeClaudeHookNotification(rawInput: rawInput) + var summary = summarizeClaudeHookNotification(rawInput: rawInput) var workspaceId = fallbackWorkspaceId var preferredSurface = surfaceArg @@ -8552,6 +8595,12 @@ struct CMUXCLI { let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) { workspaceId = mappedWorkspace preferredSurface = mapped.surfaceId + // If PreToolUse saved a richer message (e.g. from AskUserQuestion), + // use it instead of the generic notification text. + if let savedBody = mapped.lastBody, !savedBody.isEmpty, + summary.body.contains("needs your attention") || summary.body.contains("needs your input") { + summary = (subtitle: mapped.lastSubtitle ?? summary.subtitle, body: savedBody) + } } let surfaceId = try resolveSurfaceIdForClaudeHook( @@ -8586,11 +8635,86 @@ struct CMUXCLI { ) print(response) + case "session-end": + telemetry.breadcrumb("claude-hook.session-end") + // Final cleanup when Claude process exits. + // Only clear when we are the primary cleanup path (Stop didn't fire first). + // If Stop already consumed the session, consumedSession is nil and we skip + // to avoid wiping the completion notification that Stop just delivered. + let consumedSession = try? sessionStore.consume( + sessionId: parsedInput.sessionId, + workspaceId: fallbackWorkspaceId, + surfaceId: fallbackSurfaceId + ) + if let consumedSession { + let workspaceId = consumedSession.workspaceId + _ = try? clearClaudeStatus(client: client, workspaceId: workspaceId) + _ = try? sendV1Command("clear_agent_pid claude_code --tab=\(workspaceId)", client: client) + _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) + } + print("OK") + + case "pre-tool-use": + telemetry.breadcrumb("claude-hook.pre-tool-use") + // Clears "Needs input" status and notification when Claude resumes work + // (e.g. after permission grant). Runs async so it doesn't block tool execution. + var workspaceId = fallbackWorkspaceId + var claudePid: Int? = nil + if let sessionId = parsedInput.sessionId, + let mapped = try? sessionStore.lookup(sessionId: sessionId), + let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) { + workspaceId = mappedWorkspace + claudePid = mapped.pid + } + + // AskUserQuestion means Claude is about to ask the user something. + // Save question text in session so the Notification handler can use it + // instead of the generic "Claude Code needs your attention". + if let toolName = parsedInput.object?["tool_name"] as? String, + toolName == "AskUserQuestion", + let question = describeAskUserQuestion(parsedInput.object), + let sessionId = parsedInput.sessionId { + // Preserve the existing surfaceId from SessionStart; passing "" + // would overwrite it and cause notifications to target the wrong workspace. + let existingSurfaceId = (try? sessionStore.lookup(sessionId: sessionId))?.surfaceId ?? "" + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: existingSurfaceId, + cwd: parsedInput.cwd, + lastSubtitle: "Waiting", + lastBody: question + ) + // Don't clear notifications or set status here. + // The Notification hook fires right after and will use the saved question. + print("OK") + return + } + + _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) + + let statusValue: String + if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"), + let toolStatus = describeToolUse(parsedInput.object) { + statusValue = toolStatus + } else { + statusValue = "Running" + } + try setClaudeStatus( + client: client, + workspaceId: workspaceId, + value: statusValue, + icon: "bolt.fill", + color: "#4C8DFF", + pid: claudePid + ) + print("OK") + case "help", "--help", "-h": telemetry.breadcrumb("claude-hook.help") print( """ - cmux claude-hook [--workspace ] [--surface ] + cmux claude-hook [--workspace ] [--surface ] """ ) @@ -8604,17 +8728,105 @@ struct CMUXCLI { workspaceId: String, value: String, icon: String, - color: String + color: String, + pid: Int? = nil ) throws { - _ = try client.send( - command: "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)" - ) + var cmd = "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)" + if let pid { + cmd += " --pid=\(pid)" + } + _ = try client.send(command: cmd) } private func clearClaudeStatus(client: SocketClient, workspaceId: String) throws { _ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)") } + private func describeAskUserQuestion(_ object: [String: Any]?) -> String? { + guard let object, + let input = object["tool_input"] as? [String: Any], + let questions = input["questions"] as? [[String: Any]], + let first = questions.first else { return nil } + + var parts: [String] = [] + + if let question = first["question"] as? String, !question.isEmpty { + parts.append(question) + } else if let header = first["header"] as? String, !header.isEmpty { + parts.append(header) + } + + if let options = first["options"] as? [[String: Any]] { + let labels = options.compactMap { $0["label"] as? String } + if !labels.isEmpty { + parts.append(labels.map { "[\($0)]" }.joined(separator: " ")) + } + } + + if parts.isEmpty { return "Asking a question" } + return parts.joined(separator: "\n") + } + + private func describeToolUse(_ object: [String: Any]?) -> String? { + guard let object, let toolName = object["tool_name"] as? String else { return nil } + let input = object["tool_input"] as? [String: Any] + + switch toolName { + case "Read": + if let path = input?["file_path"] as? String { + return "Reading \(shortenPath(path))" + } + return "Reading file" + case "Edit": + if let path = input?["file_path"] as? String { + return "Editing \(shortenPath(path))" + } + return "Editing file" + case "Write": + if let path = input?["file_path"] as? String { + return "Writing \(shortenPath(path))" + } + return "Writing file" + case "Bash": + if let cmd = input?["command"] as? String { + let first = cmd.components(separatedBy: .whitespacesAndNewlines).first ?? cmd + let short = String(first.prefix(30)) + return "Running \(short)" + } + return "Running command" + case "Glob": + if let pattern = input?["pattern"] as? String { + return "Searching \(String(pattern.prefix(30)))" + } + return "Searching files" + case "Grep": + if let pattern = input?["pattern"] as? String { + return "Grep \(String(pattern.prefix(30)))" + } + return "Searching code" + case "Agent": + if let desc = input?["description"] as? String { + return String(desc.prefix(40)) + } + return "Subagent" + case "WebFetch": + return "Fetching URL" + case "WebSearch": + if let query = input?["query"] as? String { + return "Search: \(String(query.prefix(30)))" + } + return "Web search" + default: + return toolName + } + } + + private func shortenPath(_ path: String) -> String { + let url = URL(fileURLWithPath: path) + let name = url.lastPathComponent + return name.isEmpty ? String(path.suffix(30)) : name + } + private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String { if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) { let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate]) @@ -8816,20 +9028,13 @@ struct CMUXCLI { let signal = signalParts.compactMap { $0 }.joined(separator: " ") var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) - if let session, !session.isEmpty { - let shortSession = String(session.prefix(8)) - if !classified.body.contains(shortSession) { - classified.body = "\(classified.body) [\(shortSession)]" - } - } - classified.body = truncate(classified.body, maxLength: 180) return classified } private func classifyClaudeNotification(signal: String, message: String) -> (subtitle: String, body: String) { let lower = "\(signal) \(message)".lowercased() - if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") { + if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") || lower.contains("permission_prompt") { let body = message.isEmpty ? "Approval needed" : message return ("Permission", body) } @@ -8837,12 +9042,19 @@ struct CMUXCLI { let body = message.isEmpty ? "Claude reported an error" : message return ("Error", body) } - if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("prompt") { - let body = message.isEmpty ? "Claude is waiting for your input" : message + if lower.contains("complet") || lower.contains("finish") || lower.contains("done") || lower.contains("success") { + let body = message.isEmpty ? "Task completed" : message + return ("Completed", body) + } + if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("idle_prompt") { + let body = message.isEmpty ? "Waiting for input" : message return ("Waiting", body) } - let body = message.isEmpty ? "Claude needs your input" : message - return ("Attention", body) + // Use the message directly if it's meaningful (not a generic placeholder). + if !message.isEmpty, message != "Claude needs your input" { + return ("Attention", message) + } + return ("Attention", "Claude needs your attention") } private func dedupeBranchContextLines(_ value: String) -> String { @@ -8906,9 +9118,8 @@ struct CMUXCLI { } private func sanitizeNotificationField(_ value: String) -> String { - let normalized = normalizedSingleLine(value) + return normalizedSingleLine(value) .replacingOccurrences(of: "|", with: "¦") - return truncate(normalized, maxLength: 180) } private func versionSummary() -> String { diff --git a/Resources/bin/claude b/Resources/bin/claude index 02939248..763bf5b1 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -76,9 +76,17 @@ for arg in "$@"; do esac done +# Export the wrapper's PID. Because we exec claude below, this PID becomes +# the actual claude process PID, which hooks use for stale-session detection. +export CMUX_CLAUDE_PID=$$ + # Build hooks settings JSON. # Claude Code merges --settings additively with the user's own settings.json. -HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}]}}' +# - SessionStart/Stop/Notification: existing lifecycle hooks +# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire) +# - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt +# - PreToolUse: clears "Needs input" when Claude resumes after permission grant (async to avoid latency) +HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook pre-tool-use","timeout":5,"async":true}]}]}}' if [[ "$SKIP_SESSION_ID" == true ]]; then exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@" diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index becf4fba..765a80e8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1923,7 +1923,15 @@ class GhosttyApp { let tabId = tabManager.selectedTabId else { return false } - let tabTitle = tabManager.titleForTab(tabId) ?? "Terminal" + // Suppress OSC notifications for workspaces with active Claude hook sessions. + // The hook system manages notifications with proper lifecycle tracking; + // raw OSC notifications would duplicate or outlive the structured hooks. + let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager + if let workspace = owningManager.tabs.first(where: { $0.id == tabId }), + workspace.agentPIDs["claude_code"] != nil { + return true + } + let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody let surfaceId = tabManager.focusedSurfaceId(for: tabId) @@ -2195,7 +2203,13 @@ class GhosttyApp { let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" performOnMain { - let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" + // Suppress OSC notifications for workspaces with active Claude hook sessions. + let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager + if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }), + workspace.agentPIDs["claude_code"] != nil { + return + } + let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody TerminalNotificationStore.shared.addNotification( diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index fb666864..5cb8dbba 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -751,6 +751,7 @@ class TabManager: ObservableObject { return tabs.first(where: { $0.id == selectedTabId }) } } + private var agentPIDSweepTimer: DispatchSourceTimer? #if DEBUG private var debugWorkspaceSwitchCounter: UInt64 = 0 private var debugWorkspaceSwitchId: UInt64 = 0 @@ -796,6 +797,8 @@ class TabManager: ObservableObject { } }) + startAgentPIDSweepTimer() + #if DEBUG setupUITestFocusShortcutsIfNeeded() setupSplitCloseRightUITestIfNeeded() @@ -806,6 +809,54 @@ class TabManager: ObservableObject { deinit { workspaceCycleCooldownTask?.cancel() + agentPIDSweepTimer?.cancel() + } + + // MARK: - Agent PID Sweep + + /// Periodically checks agent PIDs associated with status entries. + /// If a process has exited (SIGKILL, crash, etc.), clears the stale status entry. + /// This is the safety net for cases where no hook fires (e.g. SIGKILL). + private func startAgentPIDSweepTimer() { + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + timer.schedule(deadline: .now() + 30, repeating: 30) + timer.setEventHandler { [weak self] in + guard let self else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.sweepStaleAgentPIDs() + } + } + timer.resume() + agentPIDSweepTimer = timer + } + + private func sweepStaleAgentPIDs() { + for tab in tabs { + var keysToRemove: [String] = [] + for (key, pid) in tab.agentPIDs { + guard pid > 0 else { + keysToRemove.append(key) + continue + } + // kill(pid, 0) probes process liveness without sending a signal. + // ESRCH = process doesn't exist (stale). EPERM = process exists + // but we lack permission (not stale, keep tracking). + errno = 0 + if kill(pid, 0) == -1, POSIXErrorCode(rawValue: errno) == .ESRCH { + keysToRemove.append(key) + } + } + if !keysToRemove.isEmpty { + for key in keysToRemove { + tab.statusEntries.removeValue(forKey: key) + tab.agentPIDs.removeValue(forKey: key) + } + // Also clear stale notifications (e.g. "Doing well, thanks!") + // left behind when Claude was killed without SessionEnd firing. + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id) + } + } } private func wireClosedBrowserTracking(for workspace: Workspace) { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0374533d..dccd9cb1 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1562,6 +1562,12 @@ class TerminalController { case "clear_status": return clearStatus(args) + case "set_agent_pid": + return setAgentPID(args) + + case "clear_agent_pid": + return clearAgentPID(args) + case "clear_meta": return clearMeta(args) @@ -13061,6 +13067,14 @@ class TerminalController { return tabResolution.error ?? "ERROR: No tab selected" } + let pidValue: pid_t? = { + if let rawPid = normalizedOptionValue(parsed.options["pid"]), + let p = Int32(rawPid), p > 0 { + return p + } + return nil + }() + DispatchQueue.main.async { [weak self] in guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } guard Self.shouldReplaceStatusEntry( @@ -13073,6 +13087,10 @@ class TerminalController { priority: priority, format: format ) else { + // Still update PID tracking even if the status display hasn't changed. + if let pidValue { + tab.agentPIDs[key] = pidValue + } return } tab.statusEntries[key] = SidebarStatusEntry( @@ -13085,6 +13103,9 @@ class TerminalController { format: format, timestamp: Date() ) + if let pidValue { + tab.agentPIDs[key] = pidValue + } } return "OK" } @@ -13104,10 +13125,50 @@ class TerminalController { if tab.statusEntries.removeValue(forKey: key) == nil { result = "OK (key not found)" } + tab.agentPIDs.removeValue(forKey: key) } return result } + /// Register an agent PID for stale-session detection without setting a visible status entry. + /// Usage: set_agent_pid [--tab=] + private func setAgentPID(_ args: String) -> String { + let parsed = parseOptions(args) + guard parsed.positional.count >= 2, + let pid = Int32(parsed.positional[1]), pid > 0 else { + return "ERROR: Usage: set_agent_pid [--tab=]" + } + let key = parsed.positional[0] + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + tab.agentPIDs[key] = pid + } + return "OK" + } + + /// Unregister an agent PID. Usage: clear_agent_pid [--tab=] + private func clearAgentPID(_ args: String) -> String { + let parsed = parseOptions(args) + guard let key = parsed.positional.first else { + return "ERROR: Usage: clear_agent_pid [--tab=]" + } + // Resolve tab ID synchronously before dispatching to avoid + // racing against selection changes on the main queue. + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + tab.agentPIDs.removeValue(forKey: key) + } + return "OK" + } + private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String { var line = "\(entry.key)=\(entry.value)" if let icon = entry.icon { line += " icon=\(icon)" } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 02335c6b..b0d65f39 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -195,20 +195,11 @@ extension Workspace { setCustomColor(snapshot.customColor) isPinned = snapshot.isPinned - statusEntries = Dictionary( - uniqueKeysWithValues: snapshot.statusEntries.map { entry in - ( - entry.key, - SidebarStatusEntry( - key: entry.key, - value: entry.value, - icon: entry.icon, - color: entry.color, - timestamp: Date(timeIntervalSince1970: entry.timestamp) - ) - ) - } - ) + // Status entries and agent PIDs are ephemeral runtime state tied to running + // processes (e.g. claude_code "Running"). Don't restore them across app + // restarts because the processes that set them are gone. + statusEntries.removeAll() + agentPIDs.removeAll() logEntries = snapshot.logEntries.map { entry in SidebarLogEntry( message: entry.message, @@ -1006,6 +997,9 @@ final class Workspace: Identifiable, ObservableObject { @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] + /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. + /// Used for stale-session detection: if the PID is dead, the status entry is cleared. + var agentPIDs: [String: pid_t] = [:] private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] var focusedSurfaceId: UUID? { focusedPanelId } @@ -1747,6 +1741,7 @@ final class Workspace: Identifiable, ObservableObject { func resetSidebarContext(reason: String = "unspecified") { statusEntries.removeAll() + agentPIDs.removeAll() logEntries.removeAll() progress = nil gitBranch = nil diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 7763bd76..2ad1298b 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -139,10 +139,22 @@ def test_live_socket_injects_supported_hooks(failures: list[str]) -> None: settings = parse_settings_arg(real_argv) hooks = settings.get("hooks", {}) - expect(set(hooks.keys()) == {"SessionStart", "Stop", "Notification"}, f"unexpected hook keys: {hooks.keys()}", failures) - serialized = json.dumps(settings, sort_keys=True) - expect("UserPromptSubmit" not in serialized, "UserPromptSubmit hook should not be injected", failures) - expect("prompt-submit" not in serialized, "prompt-submit subcommand should not be injected", failures) + expected_hooks = {"SessionStart", "Stop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse"} + expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures) + # PreToolUse should be async to avoid blocking tool execution + pre_tool_use_hooks = hooks.get("PreToolUse", [{}])[0].get("hooks", [{}]) + expect( + any(h.get("async") is True for h in pre_tool_use_hooks), + f"PreToolUse hook should have async:true, got {pre_tool_use_hooks}", + failures, + ) + # SessionEnd should have a short timeout (session is exiting) + session_end_hooks = hooks.get("SessionEnd", [{}])[0].get("hooks", [{}]) + expect( + any(h.get("timeout", 999) <= 2 for h in session_end_hooks), + f"SessionEnd hook should have short timeout, got {session_end_hooks}", + failures, + ) def test_missing_socket_skips_hook_injection(failures: list[str]) -> None: