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:
parent
85ebbb686f
commit
623262493b
7 changed files with 418 additions and 66 deletions
301
CLI/cmux.swift
301
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 <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,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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" "$@"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 <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 {
|
||||
var line = "\(entry.key)=\(entry.value)"
|
||||
if let icon = entry.icon { line += " icon=\(icon)" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue