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
This commit is contained in:
Lawrence Chen 2026-03-13 20:47:42 -07:00 committed by GitHub
parent 85ebbb686f
commit 623262493b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 418 additions and 66 deletions

View file

@ -316,6 +316,7 @@ private struct ClaudeHookSessionRecord: Codable {
var workspaceId: String var workspaceId: String
var surfaceId: String var surfaceId: String
var cwd: String? var cwd: String?
var pid: Int?
var lastSubtitle: String? var lastSubtitle: String?
var lastBody: String? var lastBody: String?
var startedAt: TimeInterval var startedAt: TimeInterval
@ -363,6 +364,7 @@ private final class ClaudeHookSessionStore {
workspaceId: String, workspaceId: String,
surfaceId: String, surfaceId: String,
cwd: String?, cwd: String?,
pid: Int? = nil,
lastSubtitle: String? = nil, lastSubtitle: String? = nil,
lastBody: String? = nil lastBody: String? = nil
) throws { ) throws {
@ -375,16 +377,22 @@ private final class ClaudeHookSessionStore {
workspaceId: workspaceId, workspaceId: workspaceId,
surfaceId: surfaceId, surfaceId: surfaceId,
cwd: nil, cwd: nil,
pid: nil,
lastSubtitle: nil, lastSubtitle: nil,
lastBody: nil, lastBody: nil,
startedAt: now, startedAt: now,
updatedAt: now updatedAt: now
) )
record.workspaceId = workspaceId record.workspaceId = workspaceId
if !surfaceId.isEmpty {
record.surfaceId = surfaceId record.surfaceId = surfaceId
}
if let cwd = normalizeOptional(cwd) { if let cwd = normalizeOptional(cwd) {
record.cwd = cwd record.cwd = cwd
} }
if let pid {
record.pid = pid
}
if let subtitle = normalizeOptional(lastSubtitle) { if let subtitle = normalizeOptional(lastSubtitle) {
record.lastSubtitle = subtitle record.lastSubtitle = subtitle
} }
@ -8477,39 +8485,68 @@ struct CMUXCLI {
workspaceId: workspaceId, workspaceId: workspaceId,
client: client 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 { if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert( try? sessionStore.upsert(
sessionId: sessionId, sessionId: sessionId,
workspaceId: workspaceId, workspaceId: workspaceId,
surfaceId: surfaceId, surfaceId: surfaceId,
cwd: parsedInput.cwd cwd: parsedInput.cwd,
pid: claudePid
) )
} }
try setClaudeStatus( // Register PID for stale-session detection and OSC suppression,
client: client, // but don't set a visible status. "Running" only appears when the
workspaceId: workspaceId, // user submits a prompt (UserPromptSubmit) or Claude starts working
value: "Running", // (PreToolUse).
icon: "bolt.fill", if let claudePid {
color: "#4C8DFF" _ = try? sendV1Command(
"set_agent_pid claude_code \(claudePid) --tab=\(workspaceId)",
client: client
) )
}
print("OK") print("OK")
case "stop", "idle": case "stop", "idle":
telemetry.breadcrumb("claude-hook.stop") telemetry.breadcrumb("claude-hook.stop")
let consumedSession = try? sessionStore.consume( // Turn ended. Don't consume session or clear PID Claude is still alive.
sessionId: parsedInput.sessionId, // Notification hook handles user-facing notifications; SessionEnd handles cleanup.
workspaceId: fallbackWorkspaceId, var workspaceId = fallbackWorkspaceId
surfaceId: fallbackSurfaceId var surfaceId = surfaceArg
) if let sessionId = parsedInput.sessionId,
let workspaceId = consumedSession?.workspaceId ?? fallbackWorkspaceId let mapped = try? sessionStore.lookup(sessionId: sessionId),
try clearClaudeStatus(client: client, workspaceId: workspaceId) 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, parsedInput: parsedInput,
sessionRecord: consumedSession sessionRecord: (try? sessionStore.lookup(sessionId: parsedInput.sessionId ?? ""))
) { )
let surfaceId = try resolveSurfaceIdForClaudeHook( if let sessionId = parsedInput.sessionId, let completion {
consumedSession?.surfaceId ?? surfaceArg, 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, workspaceId: workspaceId,
client: client client: client
) )
@ -8517,12 +8554,18 @@ struct CMUXCLI {
let subtitle = sanitizeNotificationField(completion.subtitle) let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body) let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)" let payload = "\(title)|\(subtitle)|\(body)"
let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) _ = try? sendV1Command("notify_target \(workspaceId) \(resolvedSurface) \(payload)", client: client)
print(response)
} else {
print("OK")
} }
try setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Idle",
icon: "pause.circle.fill",
color: "#8E8E93"
)
print("OK")
case "prompt-submit": case "prompt-submit":
telemetry.breadcrumb("claude-hook.prompt-submit") telemetry.breadcrumb("claude-hook.prompt-submit")
var workspaceId = fallbackWorkspaceId var workspaceId = fallbackWorkspaceId
@ -8543,7 +8586,7 @@ struct CMUXCLI {
case "notification", "notify": case "notification", "notify":
telemetry.breadcrumb("claude-hook.notification") telemetry.breadcrumb("claude-hook.notification")
let summary = summarizeClaudeHookNotification(rawInput: rawInput) var summary = summarizeClaudeHookNotification(rawInput: rawInput)
var workspaceId = fallbackWorkspaceId var workspaceId = fallbackWorkspaceId
var preferredSurface = surfaceArg var preferredSurface = surfaceArg
@ -8552,6 +8595,12 @@ struct CMUXCLI {
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) { let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace workspaceId = mappedWorkspace
preferredSurface = mapped.surfaceId 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( let surfaceId = try resolveSurfaceIdForClaudeHook(
@ -8586,11 +8635,86 @@ struct CMUXCLI {
) )
print(response) 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": case "help", "--help", "-h":
telemetry.breadcrumb("claude-hook.help") telemetry.breadcrumb("claude-hook.help")
print( print(
""" """
cmux claude-hook <session-start|stop|notification> [--workspace <id|index>] [--surface <id|index>] cmux claude-hook <session-start|stop|session-end|notification|prompt-submit|pre-tool-use> [--workspace <id|index>] [--surface <id|index>]
""" """
) )
@ -8604,17 +8728,105 @@ struct CMUXCLI {
workspaceId: String, workspaceId: String,
value: String, value: String,
icon: String, icon: String,
color: String color: String,
pid: Int? = nil
) throws { ) throws {
_ = try client.send( var cmd = "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
command: "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 { private func clearClaudeStatus(client: SocketClient, workspaceId: String) throws {
_ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)") _ = 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 { private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String {
if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) { if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) {
let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate]) 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: " ") let signal = signalParts.compactMap { $0 }.joined(separator: " ")
var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) 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) classified.body = truncate(classified.body, maxLength: 180)
return classified return classified
} }
private func classifyClaudeNotification(signal: String, message: String) -> (subtitle: String, body: String) { private func classifyClaudeNotification(signal: String, message: String) -> (subtitle: String, body: String) {
let lower = "\(signal) \(message)".lowercased() 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 let body = message.isEmpty ? "Approval needed" : message
return ("Permission", body) return ("Permission", body)
} }
@ -8837,12 +9042,19 @@ struct CMUXCLI {
let body = message.isEmpty ? "Claude reported an error" : message let body = message.isEmpty ? "Claude reported an error" : message
return ("Error", body) return ("Error", body)
} }
if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("prompt") { if lower.contains("complet") || lower.contains("finish") || lower.contains("done") || lower.contains("success") {
let body = message.isEmpty ? "Claude is waiting for your input" : message 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) return ("Waiting", body)
} }
let body = message.isEmpty ? "Claude needs your input" : message // Use the message directly if it's meaningful (not a generic placeholder).
return ("Attention", body) if !message.isEmpty, message != "Claude needs your input" {
return ("Attention", message)
}
return ("Attention", "Claude needs your attention")
} }
private func dedupeBranchContextLines(_ value: String) -> String { private func dedupeBranchContextLines(_ value: String) -> String {
@ -8906,9 +9118,8 @@ struct CMUXCLI {
} }
private func sanitizeNotificationField(_ value: String) -> String { private func sanitizeNotificationField(_ value: String) -> String {
let normalized = normalizedSingleLine(value) return normalizedSingleLine(value)
.replacingOccurrences(of: "|", with: "¦") .replacingOccurrences(of: "|", with: "¦")
return truncate(normalized, maxLength: 180)
} }
private func versionSummary() -> String { private func versionSummary() -> String {

View file

@ -76,9 +76,17 @@ for arg in "$@"; do
esac esac
done 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. # Build hooks settings JSON.
# Claude Code merges --settings additively with the user's own 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 if [[ "$SKIP_SESSION_ID" == true ]]; then
exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@" exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"

View file

@ -1923,7 +1923,15 @@ class GhosttyApp {
let tabId = tabManager.selectedTabId else { let tabId = tabManager.selectedTabId else {
return false 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 command = actionTitle.isEmpty ? tabTitle : actionTitle
let body = actionBody let body = actionBody
let surfaceId = tabManager.focusedSurfaceId(for: tabId) let surfaceId = tabManager.focusedSurfaceId(for: tabId)
@ -2195,7 +2203,13 @@ class GhosttyApp {
let actionBody = action.action.desktop_notification.body let actionBody = action.action.desktop_notification.body
.flatMap { String(cString: $0) } ?? "" .flatMap { String(cString: $0) } ?? ""
performOnMain { 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 command = actionTitle.isEmpty ? tabTitle : actionTitle
let body = actionBody let body = actionBody
TerminalNotificationStore.shared.addNotification( TerminalNotificationStore.shared.addNotification(

View file

@ -751,6 +751,7 @@ class TabManager: ObservableObject {
return tabs.first(where: { $0.id == selectedTabId }) return tabs.first(where: { $0.id == selectedTabId })
} }
} }
private var agentPIDSweepTimer: DispatchSourceTimer?
#if DEBUG #if DEBUG
private var debugWorkspaceSwitchCounter: UInt64 = 0 private var debugWorkspaceSwitchCounter: UInt64 = 0
private var debugWorkspaceSwitchId: UInt64 = 0 private var debugWorkspaceSwitchId: UInt64 = 0
@ -796,6 +797,8 @@ class TabManager: ObservableObject {
} }
}) })
startAgentPIDSweepTimer()
#if DEBUG #if DEBUG
setupUITestFocusShortcutsIfNeeded() setupUITestFocusShortcutsIfNeeded()
setupSplitCloseRightUITestIfNeeded() setupSplitCloseRightUITestIfNeeded()
@ -806,6 +809,54 @@ class TabManager: ObservableObject {
deinit { deinit {
workspaceCycleCooldownTask?.cancel() 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) { private func wireClosedBrowserTracking(for workspace: Workspace) {

View file

@ -1562,6 +1562,12 @@ class TerminalController {
case "clear_status": case "clear_status":
return clearStatus(args) return clearStatus(args)
case "set_agent_pid":
return setAgentPID(args)
case "clear_agent_pid":
return clearAgentPID(args)
case "clear_meta": case "clear_meta":
return clearMeta(args) return clearMeta(args)
@ -13061,6 +13067,14 @@ class TerminalController {
return tabResolution.error ?? "ERROR: No tab selected" 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 DispatchQueue.main.async { [weak self] in
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
guard Self.shouldReplaceStatusEntry( guard Self.shouldReplaceStatusEntry(
@ -13073,6 +13087,10 @@ class TerminalController {
priority: priority, priority: priority,
format: format format: format
) else { ) else {
// Still update PID tracking even if the status display hasn't changed.
if let pidValue {
tab.agentPIDs[key] = pidValue
}
return return
} }
tab.statusEntries[key] = SidebarStatusEntry( tab.statusEntries[key] = SidebarStatusEntry(
@ -13085,6 +13103,9 @@ class TerminalController {
format: format, format: format,
timestamp: Date() timestamp: Date()
) )
if let pidValue {
tab.agentPIDs[key] = pidValue
}
} }
return "OK" return "OK"
} }
@ -13104,10 +13125,50 @@ class TerminalController {
if tab.statusEntries.removeValue(forKey: key) == nil { if tab.statusEntries.removeValue(forKey: key) == nil {
result = "OK (key not found)" result = "OK (key not found)"
} }
tab.agentPIDs.removeValue(forKey: key)
} }
return result return result
} }
/// Register an agent PID for stale-session detection without setting a visible status entry.
/// Usage: set_agent_pid <key> <pid> [--tab=<id>]
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 <key> <pid> [--tab=<id>]"
}
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 <key> [--tab=<id>]
private func clearAgentPID(_ args: String) -> String {
let parsed = parseOptions(args)
guard let key = parsed.positional.first else {
return "ERROR: Usage: clear_agent_pid <key> [--tab=<id>]"
}
// 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 { private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String {
var line = "\(entry.key)=\(entry.value)" var line = "\(entry.key)=\(entry.value)"
if let icon = entry.icon { line += " icon=\(icon)" } if let icon = entry.icon { line += " icon=\(icon)" }

View file

@ -195,20 +195,11 @@ extension Workspace {
setCustomColor(snapshot.customColor) setCustomColor(snapshot.customColor)
isPinned = snapshot.isPinned isPinned = snapshot.isPinned
statusEntries = Dictionary( // Status entries and agent PIDs are ephemeral runtime state tied to running
uniqueKeysWithValues: snapshot.statusEntries.map { entry in // processes (e.g. claude_code "Running"). Don't restore them across app
( // restarts because the processes that set them are gone.
entry.key, statusEntries.removeAll()
SidebarStatusEntry( agentPIDs.removeAll()
key: entry.key,
value: entry.value,
icon: entry.icon,
color: entry.color,
timestamp: Date(timeIntervalSince1970: entry.timestamp)
)
)
}
)
logEntries = snapshot.logEntries.map { entry in logEntries = snapshot.logEntries.map { entry in
SidebarLogEntry( SidebarLogEntry(
message: entry.message, message: entry.message,
@ -1006,6 +997,9 @@ final class Workspace: Identifiable, ObservableObject {
@Published var listeningPorts: [Int] = [] @Published var listeningPorts: [Int] = []
var surfaceTTYNames: [UUID: String] = [:] var surfaceTTYNames: [UUID: String] = [:]
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] 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] = [:] private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
var focusedSurfaceId: UUID? { focusedPanelId } var focusedSurfaceId: UUID? { focusedPanelId }
@ -1747,6 +1741,7 @@ final class Workspace: Identifiable, ObservableObject {
func resetSidebarContext(reason: String = "unspecified") { func resetSidebarContext(reason: String = "unspecified") {
statusEntries.removeAll() statusEntries.removeAll()
agentPIDs.removeAll()
logEntries.removeAll() logEntries.removeAll()
progress = nil progress = nil
gitBranch = nil gitBranch = nil

View file

@ -139,10 +139,22 @@ def test_live_socket_injects_supported_hooks(failures: list[str]) -> None:
settings = parse_settings_arg(real_argv) settings = parse_settings_arg(real_argv)
hooks = settings.get("hooks", {}) hooks = settings.get("hooks", {})
expect(set(hooks.keys()) == {"SessionStart", "Stop", "Notification"}, f"unexpected hook keys: {hooks.keys()}", failures) expected_hooks = {"SessionStart", "Stop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse"}
serialized = json.dumps(settings, sort_keys=True) expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures)
expect("UserPromptSubmit" not in serialized, "UserPromptSubmit hook should not be injected", failures) # PreToolUse should be async to avoid blocking tool execution
expect("prompt-submit" not in serialized, "prompt-submit subcommand should not be injected", failures) 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: def test_missing_socket_skips_hook_injection(failures: list[str]) -> None: