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 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
|
||||||
record.surfaceId = surfaceId
|
if !surfaceId.isEmpty {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 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")
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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" "$@"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)" }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue