From 777d6b048e21224bb59218666132116f563cb678 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:33:36 -0800 Subject: [PATCH] Replace CLAUDE_CONFIG_DIR with claude wrapper + richer notifications Instead of creating a merged config directory and injecting CLAUDE_CONFIG_DIR on every terminal spawn, place a thin wrapper script at Resources/bin/claude that intercepts claude invocations to inject --session-id and --settings flags. This eliminates blocking I/O on terminal creation and removes config management complexity. - Add Resources/bin/claude wrapper script with hook injection - Add shell integration PATH fix (re-prepend after .zshrc/.bashrc) - Add transcript reading for richer stop notifications - Add set_status/clear_status to notifications socket allowlist - Add Settings toggle to disable Claude Code integration - Update docs to reflect automatic integration approach - Unset CLAUDECODE env var to avoid nested session detection --- CLI/cmux.swift | 595 ++++++++++++++++++ GhosttyTabs.xcodeproj/project.pbxproj | 4 + .../CmuxWebViewKeyEquivalentTests.swift | 173 ++++- Resources/bin/claude | 41 ++ .../cmux-bash-integration.bash | 18 + .../cmux-zsh-integration.zsh | 18 + Sources/GhosttyTerminalView.swift | 5 + Sources/TerminalController.swift | 5 +- Sources/cmuxApp.swift | 86 ++- docs-site/content/docs/claude-code-hooks.mdx | 291 ++------- 10 files changed, 974 insertions(+), 262 deletions(-) create mode 100755 Resources/bin/claude diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1934da86..931b23b0 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -59,6 +59,202 @@ struct NotificationInfo { let body: String } +private struct ClaudeHookParsedInput { + let rawInput: String + let object: [String: Any]? + let sessionId: String? + let cwd: String? + let transcriptPath: String? +} + +private struct ClaudeHookSessionRecord: Codable { + var sessionId: String + var workspaceId: String + var surfaceId: String + var cwd: String? + var lastSubtitle: String? + var lastBody: String? + var startedAt: TimeInterval + var updatedAt: TimeInterval +} + +private struct ClaudeHookSessionStoreFile: Codable { + var version: Int = 1 + var sessions: [String: ClaudeHookSessionRecord] = [:] +} + +private final class ClaudeHookSessionStore { + private static let defaultStatePath = "~/.cmuxterm/claude-hook-sessions.json" + private static let maxStateAgeSeconds: TimeInterval = 60 * 60 * 24 * 7 + + private let statePath: String + private let fileManager: FileManager + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init( + processEnv: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) { + if let overridePath = processEnv["CMUX_CLAUDE_HOOK_STATE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !overridePath.isEmpty { + self.statePath = NSString(string: overridePath).expandingTildeInPath + } else { + self.statePath = NSString(string: Self.defaultStatePath).expandingTildeInPath + } + self.fileManager = fileManager + self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + func lookup(sessionId: String) throws -> ClaudeHookSessionRecord? { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return nil } + return try withLockedState { state in + state.sessions[normalized] + } + } + + func upsert( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + lastSubtitle: String? = nil, + lastBody: String? = nil + ) throws { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return } + try withLockedState { state in + let now = Date().timeIntervalSince1970 + var record = state.sessions[normalized] ?? ClaudeHookSessionRecord( + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: nil, + lastSubtitle: nil, + lastBody: nil, + startedAt: now, + updatedAt: now + ) + record.workspaceId = workspaceId + record.surfaceId = surfaceId + if let cwd = normalizeOptional(cwd) { + record.cwd = cwd + } + if let subtitle = normalizeOptional(lastSubtitle) { + record.lastSubtitle = subtitle + } + if let body = normalizeOptional(lastBody) { + record.lastBody = body + } + record.updatedAt = now + state.sessions[normalized] = record + } + } + + func consume( + sessionId: String?, + workspaceId: String?, + surfaceId: String? + ) throws -> ClaudeHookSessionRecord? { + let normalizedSessionId = normalizeOptional(sessionId) + let normalizedWorkspace = normalizeOptional(workspaceId) + let normalizedSurface = normalizeOptional(surfaceId) + return try withLockedState { state in + if let normalizedSessionId, + let removed = state.sessions.removeValue(forKey: normalizedSessionId) { + return removed + } + + guard let fallback = fallbackRecord( + sessions: Array(state.sessions.values), + workspaceId: normalizedWorkspace, + surfaceId: normalizedSurface + ) else { + return nil + } + state.sessions.removeValue(forKey: fallback.sessionId) + return fallback + } + } + + private func fallbackRecord( + sessions: [ClaudeHookSessionRecord], + workspaceId: String?, + surfaceId: String? + ) -> ClaudeHookSessionRecord? { + if let surfaceId { + let matches = sessions.filter { $0.surfaceId == surfaceId } + return matches.max(by: { $0.updatedAt < $1.updatedAt }) + } + if let workspaceId { + let matches = sessions.filter { $0.workspaceId == workspaceId } + if matches.count == 1 { + return matches[0] + } + } + return nil + } + + private func withLockedState(_ body: (inout ClaudeHookSessionStoreFile) throws -> T) throws -> T { + let lockPath = statePath + ".lock" + let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) + if fd < 0 { + throw CLIError(message: "Failed to open Claude hook state lock: \(lockPath)") + } + defer { Darwin.close(fd) } + + if flock(fd, LOCK_EX) != 0 { + throw CLIError(message: "Failed to lock Claude hook state: \(lockPath)") + } + defer { _ = flock(fd, LOCK_UN) } + + var state = loadUnlocked() + pruneExpired(&state) + let result = try body(&state) + try saveUnlocked(state) + return result + } + + private func loadUnlocked() -> ClaudeHookSessionStoreFile { + guard fileManager.fileExists(atPath: statePath) else { + return ClaudeHookSessionStoreFile() + } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: statePath)), + let decoded = try? decoder.decode(ClaudeHookSessionStoreFile.self, from: data) else { + return ClaudeHookSessionStoreFile() + } + return decoded + } + + private func saveUnlocked(_ state: ClaudeHookSessionStoreFile) throws { + let stateURL = URL(fileURLWithPath: statePath) + let parentURL = stateURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true, attributes: nil) + let data = try encoder.encode(state) + try data.write(to: stateURL, options: .atomic) + } + + private func pruneExpired(_ state: inout ClaudeHookSessionStoreFile) { + let now = Date().timeIntervalSince1970 + let cutoff = now - Self.maxStateAgeSeconds + state.sessions = state.sessions.filter { _, record in + record.updatedAt >= cutoff + } + } + + private func normalizeSessionId(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func normalizeOptional(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} + enum CLIIDFormat: String { case refs case uuids @@ -677,6 +873,9 @@ struct CMUXCLI { let response = try client.send(command: "clear_notifications") print(response) + case "claude-hook": + try runClaudeHook(commandArgs: commandArgs, client: client) + case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } let response = try client.send(command: "set_app_focus \(value)") @@ -2480,6 +2679,401 @@ struct CMUXCLI { return output } + private func runClaudeHook(commandArgs: [String], client: SocketClient) throws { + let subcommand = commandArgs.first?.lowercased() ?? "help" + let hookArgs = Array(commandArgs.dropFirst()) + let workspaceArg = optionValue(hookArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] + let surfaceArg = optionValue(hookArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" + let parsedInput = parseClaudeHookInput(rawInput: rawInput) + let sessionStore = ClaudeHookSessionStore() + let fallbackWorkspaceId = try resolveWorkspaceIdForClaudeHook(workspaceArg, client: client) + let fallbackSurfaceId = try? resolveSurfaceId(surfaceArg, workspaceId: fallbackWorkspaceId, client: client) + + switch subcommand { + case "session-start", "active": + let workspaceId = fallbackWorkspaceId + let surfaceId = try resolveSurfaceIdForClaudeHook( + surfaceArg, + workspaceId: workspaceId, + client: client + ) + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: parsedInput.cwd + ) + } + try setClaudeStatus( + client: client, + workspaceId: workspaceId, + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" + ) + print("OK") + + case "stop", "idle": + let consumedSession = try? sessionStore.consume( + sessionId: parsedInput.sessionId, + workspaceId: fallbackWorkspaceId, + surfaceId: fallbackSurfaceId + ) + let workspaceId = consumedSession?.workspaceId ?? fallbackWorkspaceId + try clearClaudeStatus(client: client, workspaceId: workspaceId) + + if let completion = summarizeClaudeHookStop( + parsedInput: parsedInput, + sessionRecord: consumedSession + ) { + let surfaceId = try resolveSurfaceIdForClaudeHook( + consumedSession?.surfaceId ?? surfaceArg, + workspaceId: workspaceId, + client: client + ) + let title = "Claude Code" + let subtitle = sanitizeNotificationField(completion.subtitle) + let body = sanitizeNotificationField(completion.body) + let payload = "\(title)|\(subtitle)|\(body)" + let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") + print(response) + } else { + print("OK") + } + + case "notification", "notify": + let summary = summarizeClaudeHookNotification(rawInput: rawInput) + + var workspaceId = fallbackWorkspaceId + var preferredSurface = surfaceArg + if let sessionId = parsedInput.sessionId, + let mapped = try? sessionStore.lookup(sessionId: sessionId), + let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) { + workspaceId = mappedWorkspace + preferredSurface = mapped.surfaceId + } + + let surfaceId = try resolveSurfaceIdForClaudeHook( + preferredSurface, + workspaceId: workspaceId, + client: client + ) + + let title = "Claude Code" + let subtitle = sanitizeNotificationField(summary.subtitle) + let body = sanitizeNotificationField(summary.body) + let payload = "\(title)|\(subtitle)|\(body)" + + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: parsedInput.cwd, + lastSubtitle: summary.subtitle, + lastBody: summary.body + ) + } + + let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") + _ = try? setClaudeStatus( + client: client, + workspaceId: workspaceId, + value: "Needs input", + icon: "bell.fill", + color: "#4C8DFF" + ) + print(response) + + case "help", "--help", "-h": + print( + """ + cmux claude-hook [--workspace ] [--surface ] + """ + ) + + default: + throw CLIError(message: "Unknown claude-hook subcommand: \(subcommand)") + } + } + + private func setClaudeStatus( + client: SocketClient, + workspaceId: String, + value: String, + icon: String, + color: String + ) throws { + _ = try client.send( + command: "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)" + ) + } + + private func clearClaudeStatus(client: SocketClient, workspaceId: String) throws { + _ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)") + } + + 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.send(command: "list_surfaces \(candidate)") + if let probe, !probe.hasPrefix("ERROR") { + return candidate + } + } + return try resolveWorkspaceId(nil, client: client) + } + + private func resolveSurfaceIdForClaudeHook( + _ raw: String?, + workspaceId: String, + client: SocketClient + ) throws -> String { + if let raw, !raw.isEmpty, let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client) { + return candidate + } + return try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) + } + + private func parseClaudeHookInput(rawInput: String) -> ClaudeHookParsedInput { + let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data, options: []), + let object = json as? [String: Any] else { + return ClaudeHookParsedInput(rawInput: rawInput, object: nil, sessionId: nil, cwd: nil, transcriptPath: nil) + } + + let sessionId = extractClaudeHookSessionId(from: object) + let cwd = extractClaudeHookCWD(from: object) + let transcriptPath = firstString(in: object, keys: ["transcript_path", "transcriptPath"]) + return ClaudeHookParsedInput(rawInput: rawInput, object: object, sessionId: sessionId, cwd: cwd, transcriptPath: transcriptPath) + } + + private func extractClaudeHookSessionId(from object: [String: Any]) -> String? { + if let id = firstString(in: object, keys: ["session_id", "sessionId"]) { + return id + } + + if let nested = object["notification"] as? [String: Any], + let id = firstString(in: nested, keys: ["session_id", "sessionId"]) { + return id + } + if let nested = object["data"] as? [String: Any], + let id = firstString(in: nested, keys: ["session_id", "sessionId"]) { + return id + } + if let session = object["session"] as? [String: Any], + let id = firstString(in: session, keys: ["id", "session_id", "sessionId"]) { + return id + } + if let context = object["context"] as? [String: Any], + let id = firstString(in: context, keys: ["session_id", "sessionId"]) { + return id + } + return nil + } + + private func extractClaudeHookCWD(from object: [String: Any]) -> String? { + let cwdKeys = ["cwd", "working_directory", "workingDirectory", "project_dir", "projectDir"] + if let cwd = firstString(in: object, keys: cwdKeys) { + return cwd + } + if let nested = object["notification"] as? [String: Any], + let cwd = firstString(in: nested, keys: cwdKeys) { + return cwd + } + if let nested = object["data"] as? [String: Any], + let cwd = firstString(in: nested, keys: cwdKeys) { + return cwd + } + if let context = object["context"] as? [String: Any], + let cwd = firstString(in: context, keys: cwdKeys) { + return cwd + } + return nil + } + + private func summarizeClaudeHookStop( + parsedInput: ClaudeHookParsedInput, + sessionRecord: ClaudeHookSessionRecord? + ) -> (subtitle: String, body: String)? { + let cwd = parsedInput.cwd ?? sessionRecord?.cwd + let transcriptPath = parsedInput.transcriptPath + + let projectName: String? = { + guard let cwd = cwd, !cwd.isEmpty else { return nil } + let path = NSString(string: cwd).expandingTildeInPath + let tail = URL(fileURLWithPath: path).lastPathComponent + return tail.isEmpty ? path : tail + }() + + // Try reading the transcript JSONL for a richer summary. + let transcript = transcriptPath.flatMap { readTranscriptSummary(path: $0) } + + if let lastMsg = transcript?.lastAssistantMessage { + var subtitle = "Completed" + if let projectName, !projectName.isEmpty { + subtitle = "Completed in \(projectName)" + } + return (subtitle, truncate(lastMsg, maxLength: 200)) + } + + // Fallback: use session record data. + let lastMessage = sessionRecord?.lastBody ?? sessionRecord?.lastSubtitle + let hasContext = cwd != nil || lastMessage != nil + guard hasContext else { return nil } + + var body = "Claude session completed" + if let projectName, !projectName.isEmpty { + body += " in \(projectName)" + } + if let lastMessage, !lastMessage.isEmpty { + body += ". Last: \(lastMessage)" + } + return ("Completed", body) + } + + private struct TranscriptSummary { + let lastAssistantMessage: String? + } + + private func readTranscriptSummary(path: String) -> TranscriptSummary? { + let expandedPath = NSString(string: path).expandingTildeInPath + guard let data = try? Data(contentsOf: URL(fileURLWithPath: expandedPath)) else { + return nil + } + guard let content = String(data: data, encoding: .utf8) else { return nil } + + let lines = content.components(separatedBy: "\n") + + var lastAssistantMessage: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let lineData = trimmed.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], + let message = obj["message"] as? [String: Any], + let role = message["role"] as? String, + role == "assistant" else { + continue + } + + let text = extractMessageText(from: message) + guard let text, !text.isEmpty else { continue } + lastAssistantMessage = truncate(normalizedSingleLine(text), maxLength: 120) + } + + guard lastAssistantMessage != nil else { return nil } + return TranscriptSummary(lastAssistantMessage: lastAssistantMessage) + } + + private func extractMessageText(from message: [String: Any]) -> String? { + if let content = message["content"] as? String { + return content.trimmingCharacters(in: .whitespacesAndNewlines) + } + if let contentArray = message["content"] as? [[String: Any]] { + let texts = contentArray.compactMap { block -> String? in + guard (block["type"] as? String) == "text", + let text = block["text"] as? String else { return nil } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + let joined = texts.joined(separator: " ") + return joined.isEmpty ? nil : joined + } + return nil + } + + private func summarizeClaudeHookNotification(rawInput: String) -> (subtitle: String, body: String) { + let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return ("Waiting", "Claude is waiting for your input") + } + + guard let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data, options: []), + let object = json as? [String: Any] else { + let fallback = truncate(normalizedSingleLine(trimmed), maxLength: 180) + return classifyClaudeNotification(signal: fallback, message: fallback) + } + + let nested = (object["notification"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? [:] + let signalParts = [ + firstString(in: object, keys: ["event", "event_name", "hook_event_name", "type", "kind"]), + firstString(in: object, keys: ["notification_type", "matcher", "reason"]), + firstString(in: nested, keys: ["type", "kind", "reason"]) + ] + let messageCandidates = [ + firstString(in: object, keys: ["message", "body", "text", "prompt", "error", "description"]), + firstString(in: nested, keys: ["message", "body", "text", "prompt", "error", "description"]) + ] + let session = firstString(in: object, keys: ["session_id", "sessionId"]) + let message = messageCandidates.compactMap { $0 }.first ?? "Claude needs your input" + let normalizedMessage = normalizedSingleLine(message) + 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") { + let body = message.isEmpty ? "Approval needed" : message + return ("Permission", body) + } + if lower.contains("error") || lower.contains("failed") || lower.contains("exception") { + 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 + return ("Waiting", body) + } + let body = message.isEmpty ? "Claude needs your input" : message + return ("Attention", body) + } + + private func firstString(in object: [String: Any], keys: [String]) -> String? { + for key in keys { + guard let value = object[key] else { continue } + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } + + private func normalizedSingleLine(_ value: String) -> String { + let collapsed = value.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return collapsed.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func truncate(_ value: String, maxLength: Int) -> String { + guard value.count > maxLength else { return value } + let index = value.index(value.startIndex, offsetBy: max(0, maxLength - 1)) + return String(value[.. String { + let normalized = normalizedSingleLine(value) + .replacingOccurrences(of: "|", with: "¦") + return truncate(normalized, maxLength: 180) + } + private func usage() -> String { return """ cmux - control cmux via Unix socket @@ -2529,6 +3123,7 @@ struct CMUXCLI { notify --title [--subtitle ] [--body ] [--workspace ] [--surface ] list-notifications clear-notifications + claude-hook [--workspace ] [--surface ] set-app-focus simulate-app-active diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 457a8dbf..df7a0e9c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; + C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; @@ -88,6 +89,7 @@ dstSubfolderSpec = 7; files = ( B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, + C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */, ); name = "Copy CLI"; runOnlyForDeploymentPostprocessing = 0; @@ -170,6 +172,7 @@ C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; + C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; }; A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -344,6 +347,7 @@ children = ( B2E7294509CC42FE9191870E /* xterm-ghostty */, A5002001 /* THIRD_PARTY_LICENSES.md */, + C1ADE00001A1B2C3D4E5F719 /* claude */, ); path = Resources; sourceTree = ""; diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index 11100962..ded0cecd 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -104,7 +104,7 @@ final class WorkspaceShortcutMapperTests: XCTestCase { } final class BrowserOmnibarCommandNavigationTests: XCTestCase { - func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOnly() { + func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() { XCTAssertNil( browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: false, @@ -139,12 +139,22 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { ) ) - XCTAssertNil( + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.control], + chars: "p" + ), + -1 + ) + + XCTAssertEqual( browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: true, flags: [.control], chars: "n" - ) + ), + 1 ) } } @@ -922,36 +932,56 @@ final class OmnibarStateMachineTests: XCTestCase { _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows)) XCTAssertEqual(state.selectedSuggestionIndex, 0, "Expected reopened popup to focus first row") } + + func testSuggestionsUpdatePrefersAutocompleteMatchWhenSelectionNotTracked() throws { + var state = OmnibarState() + _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) + _ = omnibarReduce(state: &state, event: .bufferChanged("gm")) + + let rows: [OmnibarSuggestion] = [ + .search(engineName: "Google", query: "gm"), + .history(url: "https://google.com/", title: "Google"), + .history(url: "https://gmail.com/", title: "Gmail"), + ] + _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows)) + XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected autocomplete candidate to become selected without explicit index state.") + XCTAssertEqual(state.selectedSuggestionID, rows[2].id) + XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[state.selectedSuggestionIndex])) + XCTAssertEqual(state.suggestions[state.selectedSuggestionIndex].completion, "https://gmail.com/") + } } final class OmnibarRemoteSuggestionMergeTests: XCTestCase { func testMergeRemoteSuggestionsInsertsBelowSearchAndDedupes() { - let base: [OmnibarSuggestion] = [ - .search(engineName: "Google", query: "go"), - .history( - BrowserHistoryStore.Entry( - id: UUID(), - url: "https://go.dev/", - title: "The Go Programming Language", - lastVisited: Date(), - visitCount: 10 - ) + let now = Date() + let entries: [BrowserHistoryStore.Entry] = [ + BrowserHistoryStore.Entry( + id: UUID(), + url: "https://go.dev/", + title: "The Go Programming Language", + lastVisited: now, + visitCount: 10 ), ] - let merged = mergeRemoteSuggestions( - baseItems: base, + let merged = buildOmnibarSuggestions( + query: "go", + engineName: "Google", + historyEntries: entries, + openTabMatches: [], remoteQueries: ["go tutorial", "go.dev", "go json"], + resolvedURL: nil, limit: 8 ) - XCTAssertEqual(merged.map(\.completion), [ - "go", - "go tutorial", - "go.dev", - "go json", - "https://go.dev/", - ]) + let completions = merged.compactMap { $0.completion } + XCTAssertGreaterThanOrEqual(completions.count, 5) + XCTAssertEqual(completions[0], "https://go.dev/") + XCTAssertEqual(completions[1], "go") + + let remoteCompletions = Array(completions.dropFirst(2)) + XCTAssertEqual(Set(remoteCompletions), Set(["go tutorial", "go.dev", "go json"])) + XCTAssertEqual(remoteCompletions.count, 3) } func testStaleRemoteSuggestionsKeptForNearbyEdits() { @@ -988,6 +1018,105 @@ final class OmnibarRemoteSuggestionMergeTests: XCTestCase { } } +final class OmnibarSuggestionRankingTests: XCTestCase { + private var fixedNow: Date { + Date(timeIntervalSinceReferenceDate: 10_000_000) + } + + func testSingleCharacterQueryPromotesAutocompletionMatchToFirstRow() { + let entries: [BrowserHistoryStore.Entry] = [ + .init( + id: UUID(), + url: "https://news.ycombinator.com/", + title: "News.YC", + lastVisited: fixedNow, + visitCount: 12, + typedCount: 1, + lastTypedAt: fixedNow + ), + .init( + id: UUID(), + url: "https://www.google.com/", + title: "Google", + lastVisited: fixedNow - 200, + visitCount: 8, + typedCount: 2, + lastTypedAt: fixedNow - 200 + ), + ] + + let results = buildOmnibarSuggestions( + query: "n", + engineName: "Google", + historyEntries: entries, + openTabMatches: [], + remoteQueries: ["search google for n", "news"], + resolvedURL: nil, + limit: 8, + now: fixedNow + ) + + XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/") + XCTAssertNotEqual(results.map(\.completion).first, "n") + XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "n", suggestion: $0) } ?? false) + } + + func testGmAutocompleteCandidateIsFirstOnExactQueryMatch() { + let entries: [BrowserHistoryStore.Entry] = [ + .init( + id: UUID(), + url: "https://google.com/", + title: "Google", + lastVisited: fixedNow, + visitCount: 4, + typedCount: 1, + lastTypedAt: fixedNow + ), + .init( + id: UUID(), + url: "https://gmail.com/", + title: "Gmail", + lastVisited: fixedNow, + visitCount: 10, + typedCount: 2, + lastTypedAt: fixedNow + ), + ] + + let results = buildOmnibarSuggestions( + query: "gm", + engineName: "Google", + historyEntries: entries, + openTabMatches: [], + remoteQueries: ["gmail", "gmail.com", "google mail"], + resolvedURL: nil, + limit: 8, + now: fixedNow + ) + + XCTAssertEqual(results.first?.completion, "https://gmail.com/") + XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0])) + + let inlineCompletion = omnibarInlineCompletionForDisplay( + typedText: "gm", + suggestions: results, + isFocused: true, + selectionRange: NSRange(location: 2, length: 0), + hasMarkedText: false + ) + XCTAssertNotNil(inlineCompletion) + } + + func testHistorySuggestionDisplaysTitleAndUrlOnSingleLine() { + let row = OmnibarSuggestion.history( + url: "https://www.example.com/path?q=1", + title: "Example Domain" + ) + XCTAssertEqual(row.listText, "Example Domain — example.com/path?q=1") + XCTAssertFalse(row.listText.contains("\n")) + } +} + @MainActor final class NotificationDockBadgeTests: XCTestCase { func testDockBadgeLabelEnabledAndCounted() { diff --git a/Resources/bin/claude b/Resources/bin/claude new file mode 100755 index 00000000..183d1963 --- /dev/null +++ b/Resources/bin/claude @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# cmux claude wrapper - injects hooks and session tracking +# +# When running inside a cmux terminal (CMUX_SURFACE_ID is set), this wrapper +# intercepts `claude` invocations to inject --session-id and --settings flags +# so that Claude Code hooks fire back into cmux for notifications/status. +# Outside cmux, it passes through to the real claude binary unchanged. + +# Find the real claude binary, skipping our own directory. +find_real_claude() { + local self_dir + self_dir="$(cd "$(dirname "$0")" && pwd)" + local IFS=: + for d in $PATH; do + [[ "$d" == "$self_dir" ]] && continue + [[ -x "$d/claude" ]] && printf '%s' "$d/claude" && return 0 + done + return 1 +} + +# Pass through if not in a cmux terminal or hooks are disabled. +if [[ -z "$CMUX_SURFACE_ID" || "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]]; then + REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; } + exec "$REAL_CLAUDE" "$@" +fi + +# Find real claude. +REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; } + +# Unset CLAUDECODE to avoid "nested session" detection — cmux terminals are +# independent sessions even when the parent shell was launched from Claude Code. +unset CLAUDECODE + +# Generate a fresh session ID for this invocation. +SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" + +# 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}]}]}}' + +exec "$REAL_CLAUDE" --session-id "$SESSION_ID" --settings "$HOOKS_JSON" "$@" diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 79787d5a..9637ba56 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -151,4 +151,22 @@ _cmux_install_prompt_command() { fi } +# Ensure Resources/bin is at the front of PATH. Shell init (.bashrc/.bash_profile) +# may prepend other dirs that push our wrapper behind the system claude binary. +_cmux_fix_path() { + if [[ -n "${GHOSTTY_BIN_DIR:-}" ]]; then + local bin_dir="${GHOSTTY_BIN_DIR%/MacOS}" + bin_dir="${bin_dir}/Resources/bin" + if [[ -d "$bin_dir" ]]; then + local new_path=":${PATH}:" + new_path="${new_path//:${bin_dir}:/:}" + new_path="${new_path#:}" + new_path="${new_path%:}" + PATH="${bin_dir}:${new_path}" + fi + fi +} +_cmux_fix_path +unset -f _cmux_fix_path + _cmux_install_prompt_command diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 7e77eef1..b4c3f020 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -301,6 +301,24 @@ _cmux_precmd() { fi } +# Ensure Resources/bin is at the front of PATH. Shell init (.zprofile/.zshrc) +# may prepend other dirs that push our wrapper behind the system claude binary. +# We fix this once on first prompt (after all init files have run). +_cmux_fix_path() { + if [[ -n "${GHOSTTY_BIN_DIR:-}" ]]; then + local bin_dir="${GHOSTTY_BIN_DIR%/MacOS}" + bin_dir="${bin_dir}/Resources/bin" + if [[ -d "$bin_dir" ]]; then + # Remove existing entry and re-prepend. + local -a parts=("${(@s/:/)PATH}") + parts=("${(@)parts:#$bin_dir}") + PATH="${bin_dir}:${(j/:/)parts}" + fi + fi + add-zsh-hook -d precmd _cmux_fix_path +} + autoload -Uz add-zsh-hook add-zsh-hook preexec _cmux_preexec add-zsh-hook precmd _cmux_precmd +add-zsh-hook precmd _cmux_fix_path diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 975ee78c..29b73468 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1210,6 +1210,11 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + let claudeHooksEnabled = UserDefaults.standard.object(forKey: "claudeCodeHooksEnabled") as? Bool ?? true + if !claudeHooksEnabled { + env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1" + } + if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path { let currentPath = env["PATH"] ?? getenv("PATH").map { String(cString: $0) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 78fb5096..f1296cd7 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6783,7 +6783,10 @@ class TerminalController { "notify_surface", "notify_target", "list_notifications", - "clear_notifications" + "clear_notifications", + "set_status", + "clear_status", + "list_status" ] return allowed.contains(command) case .off: diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 81f12cd1..a33442a7 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -401,12 +401,12 @@ struct cmuxApp: App { } Button("Back") { - (AppDelegate.shared?.tabManager ?? tabManager).navigateBack() + (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack() } .keyboardShortcut("[", modifiers: .command) Button("Forward") { - (AppDelegate.shared?.tabManager ?? tabManager).navigateForward() + (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward() } .keyboardShortcut("]", modifiers: .command) @@ -415,6 +415,25 @@ struct cmuxApp: App { } .keyboardShortcut("r", modifiers: .command) + Button("Zoom In") { + _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() + } + .keyboardShortcut("=", modifiers: .command) + + Button("Zoom Out") { + _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser() + } + .keyboardShortcut("-", modifiers: .command) + + Button("Actual Size") { + _ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser() + } + .keyboardShortcut("0", modifiers: .command) + + Button("Clear Browser History") { + BrowserHistoryStore.shared.clearHistory() + } + Button("Next Workspace") { (AppDelegate.shared?.tabManager ?? tabManager).selectNextTab() } @@ -2191,6 +2210,7 @@ struct SettingsView: View { @AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue + @AppStorage("claudeCodeHooksEnabled") private var claudeCodeHooksEnabled = true @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @@ -2200,6 +2220,8 @@ struct SettingsView: View { @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @State private var settingsTitleLeadingInset: CGFloat = 92 + @State private var showClearBrowserHistoryConfirmation = false + @State private var browserHistoryEntryCount: Int = 0 private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -2209,6 +2231,17 @@ struct SettingsView: View { SocketControlMode(rawValue: socketControlMode) ?? SocketControlSettings.defaultMode } + private var browserHistorySubtitle: String { + switch browserHistoryEntryCount { + case 0: + return "No saved pages yet." + case 1: + return "1 saved page appears in omnibar suggestions." + default: + return "\(browserHistoryEntryCount) saved pages appear in omnibar suggestions." + } + } + private func blurOpacity(forContentOffset offset: CGFloat) -> Double { guard let baseline = topBlurBaselineOffset else { return 0 } let reveal = (baseline - offset) / 24 @@ -2301,6 +2334,24 @@ struct SettingsView: View { SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH.") } + SettingsCard { + SettingsCardRow( + "Claude Code Integration", + subtitle: claudeCodeHooksEnabled + ? "Sidebar shows Claude session status and notifications." + : "Claude Code runs without cmux integration." + ) { + Toggle("", isOn: $claudeCodeHooksEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsClaudeCodeHooksToggle") + } + + SettingsCardDivider() + + SettingsCardNote("When enabled, cmux wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.") + } + SettingsSectionHeader(title: "Browser") SettingsCard { SettingsCardRow( @@ -2324,6 +2375,17 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + + SettingsCardDivider() + + SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) { + Button("Clear History…") { + showClearBrowserHistoryConfirmation = true + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(browserHistoryEntryCount == 0) + } } SettingsSectionHeader(title: "Keyboard Shortcuts") @@ -2436,11 +2498,31 @@ struct SettingsView: View { } .background(Color(nsColor: .windowBackgroundColor).ignoresSafeArea()) .toggleStyle(.switch) + .onAppear { + BrowserHistoryStore.shared.loadIfNeeded() + browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count + } + .onReceive(BrowserHistoryStore.shared.$entries) { entries in + browserHistoryEntryCount = entries.count + } + .confirmationDialog( + "Clear browser history?", + isPresented: $showClearBrowserHistoryConfirmation, + titleVisibility: .visible + ) { + Button("Clear History", role: .destructive) { + BrowserHistoryStore.shared.clearHistory() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes visited-page suggestions from the browser omnibar.") + } } private func resetAllSettings() { appearanceMode = AppearanceMode.dark.rawValue socketControlMode = SocketControlSettings.defaultMode.rawValue + claudeCodeHooksEnabled = true browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled diff --git a/docs-site/content/docs/claude-code-hooks.mdx b/docs-site/content/docs/claude-code-hooks.mdx index 0c8fb9e4..f7b99c76 100644 --- a/docs-site/content/docs/claude-code-hooks.mdx +++ b/docs-site/content/docs/claude-code-hooks.mdx @@ -1,43 +1,58 @@ --- -title: Claude Code Hooks -description: Set up notifications for Claude Code using cmux hooks +title: Claude Code Integration +description: Automatic notifications and status tracking for Claude Code sessions --- -# Claude Code Hooks +# Claude Code Integration -cmux integrates with [Claude Code](https://docs.anthropic.com/en/docs/claude-code) via hooks to notify you when tasks complete or when Claude needs attention. +cmux automatically integrates with [Claude Code](https://docs.anthropic.com/en/docs/claude-code) to show session status and notifications in the sidebar — no manual configuration needed. -## Detecting cmux +## How It Works -Before sending notifications, you should detect if you're running inside cmux to avoid conflicts with other terminals. +When you run `claude` (or `clawd`) inside a cmux terminal, cmux automatically: -### Detection Methods +1. **Injects a session ID** so each Claude invocation is tracked independently +2. **Registers hooks** for `SessionStart`, `Stop`, and `Notification` events +3. **Routes notifications** to the correct workspace and surface in the sidebar -**1. Check for the socket:** - -```bash -[ -S /tmp/cmux.sock ] && echo "In cmux" -``` - -**2. Check for the CLI:** - -```bash -command -v cmux &>/dev/null && echo "cmux available" -``` - -**3. Check the TERM_PROGRAM environment variable:** - -```bash -[ "$TERM_PROGRAM" = "ghostty" ] && [ -S /tmp/cmux.sock ] && echo "In cmux" -``` +This works by placing a thin wrapper script ahead of the real `claude` binary in your PATH. The wrapper adds `--session-id` and `--settings` flags, then `exec`s the real Claude — no extra process, no config files modified. - cmux sets `TERM_PROGRAM=ghostty` since it's built on Ghostty. Use the socket check to distinguish from regular Ghostty. + The wrapper only activates inside cmux terminals (when `CMUX_SURFACE_ID` is set). Running `claude` in any other terminal works normally without hooks. -## Setting Up Hooks +## What You See -Claude Code supports hooks that run on specific events. Add these to your `~/.claude/settings.json`: +### Session Status + +When a Claude session is active, the sidebar shows a status indicator: + +- **Running** — Claude is working +- **Needs input** — Claude is waiting for your approval or response + +### Notifications + +When Claude finishes or needs attention, a notification appears in the sidebar with context: + +- **Permission** — Claude needs approval for a tool use +- **Error** — Something went wrong +- **Completed** — Session finished, with a summary of Claude's last response + +## Disabling + +You can turn off the integration in **Settings > Automation > Claude Code Integration**. When disabled, `claude` runs without cmux hooks — no session tracking, no sidebar status, no notifications. + +New terminals pick up the setting immediately; existing terminals keep their current state. + + + You can also set `CMUX_CLAUDE_HOOKS_DISABLED=1` in your shell environment to disable per-terminal. + + +## Custom Hooks + +The automatic integration covers `SessionStart`, `Stop`, and `Notification` events. If you want to add hooks for other Claude Code events (like `PostToolUse` or `PreToolUse`), add them to your `~/.claude/settings.json` as usual — cmux's hooks are merged additively and won't conflict. + +### Example: Notify on Task Agent Completion ```json { @@ -46,221 +61,23 @@ Claude Code supports hooks that run on specific events. Add these to your `~/.cl { "matcher": "Task", "hooks": [ - "/path/to/cmux-notify.sh" + { + "type": "command", + "command": "cmux notify --title 'Claude Code' --subtitle 'Agent Finished' --body 'Task agent completed'", + "timeout": 5 + } ] } - ], - "Stop": [ - "/path/to/cmux-notify.sh" ] } } ``` -## Notification Hook Script +## Technical Details -Create a script that checks for cmux and sends notifications: - -```bash -#!/bin/bash -# ~/.claude/hooks/cmux-notify.sh - -# Only proceed if we're in cmux -if [ ! -S /tmp/cmux.sock ]; then - exit 0 -fi - -# Parse the hook event from stdin (Claude Code passes JSON) -EVENT=$(cat) - -# Extract event type and details -EVENT_TYPE=$(echo "$EVENT" | jq -r '.event // "unknown"') -TOOL_NAME=$(echo "$EVENT" | jq -r '.tool_name // ""') -SESSION=$(echo "$EVENT" | jq -r '.session_id // ""' | cut -c1-8) - -case "$EVENT_TYPE" in - "Stop") - cmux notify \ - --title "Claude Code" \ - --subtitle "Task Complete" \ - --body "Session $SESSION finished" - ;; - "PostToolUse") - if [ "$TOOL_NAME" = "Task" ]; then - cmux notify \ - --title "Claude Code" \ - --subtitle "Agent Finished" \ - --body "Task agent completed in session $SESSION" - fi - ;; -esac -``` - -Make it executable: - -```bash -chmod +x ~/.claude/hooks/cmux-notify.sh -``` - -## Example Configurations - -### Notify on All Completions - -Get notified whenever Claude Code finishes a task: - -```json -{ - "hooks": { - "Stop": [ - "~/.claude/hooks/cmux-notify.sh" - ] - } -} -``` - -### Notify on Long-Running Tasks - -Only notify for Task tool completions (agent subprocesses): - -```json -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Task", - "hooks": ["~/.claude/hooks/cmux-notify.sh"] - } - ] - } -} -``` - -### Notify on Errors - -Add error notifications: - -```bash -#!/bin/bash -# cmux-notify.sh with error handling - -if [ ! -S /tmp/cmux.sock ]; then - exit 0 -fi - -EVENT=$(cat) -EVENT_TYPE=$(echo "$EVENT" | jq -r '.event // "unknown"') -ERROR=$(echo "$EVENT" | jq -r '.error // ""') - -if [ -n "$ERROR" ] && [ "$ERROR" != "null" ]; then - cmux notify \ - --title "Claude Code Error" \ - --body "$ERROR" -elif [ "$EVENT_TYPE" = "Stop" ]; then - cmux notify \ - --title "Claude Code" \ - --body "Task complete" -fi -``` - -## Advanced: Conditional Notifications - -Only notify if the terminal is not focused: - -```bash -#!/bin/bash -# cmux-notify.sh with focus detection - -if [ ! -S /tmp/cmux.sock ]; then - exit 0 -fi - -# cmux automatically suppresses notifications for focused tabs, -# so we can always send - it will handle suppression for us - -EVENT=$(cat) -EVENT_TYPE=$(echo "$EVENT" | jq -r '.event // "unknown"') - -if [ "$EVENT_TYPE" = "Stop" ]; then - cmux notify \ - --title "Claude Code" \ - --subtitle "Ready" \ - --body "Waiting for your input" -fi -``` - -## Using OSC Sequences Directly - -If you prefer not to use the CLI, you can emit OSC sequences directly: - -```bash -#!/bin/bash -# Direct OSC notification (no CLI needed) - -if [ ! -S /tmp/cmux.sock ]; then - exit 0 -fi - -EVENT=$(cat) -EVENT_TYPE=$(echo "$EVENT" | jq -r '.event // "unknown"') - -if [ "$EVENT_TYPE" = "Stop" ]; then - # OSC 777 notification - printf '\e]777;notify;Claude Code;Task complete\a' -fi -``` - -## Full Example Setup - -1. Create the hooks directory: - -```bash -mkdir -p ~/.claude/hooks -``` - -2. Create the notification script: - -```bash -cat > ~/.claude/hooks/cmux-notify.sh << 'EOF' -#!/bin/bash -# cmux notification hook for Claude Code - -# Skip if not in cmux -[ -S /tmp/cmux.sock ] || exit 0 - -EVENT=$(cat) -EVENT_TYPE=$(echo "$EVENT" | jq -r '.event // "unknown"') -TOOL=$(echo "$EVENT" | jq -r '.tool_name // ""') - -case "$EVENT_TYPE" in - "Stop") - cmux notify --title "Claude Code" --body "Session complete" - ;; - "PostToolUse") - [ "$TOOL" = "Task" ] && cmux notify --title "Claude Code" --body "Agent finished" - ;; -esac -EOF -chmod +x ~/.claude/hooks/cmux-notify.sh -``` - -3. Configure Claude Code: - -```bash -cat > ~/.claude/settings.json << 'EOF' -{ - "hooks": { - "Stop": ["~/.claude/hooks/cmux-notify.sh"], - "PostToolUse": [ - { - "matcher": "Task", - "hooks": ["~/.claude/hooks/cmux-notify.sh"] - } - ] - } -} -EOF -``` - -4. Restart Claude Code to apply the hooks. - -Now you'll receive desktop notifications in cmux whenever Claude Code finishes a task or needs attention. +- The wrapper lives at `Resources/bin/claude` inside the app bundle +- It is prepended to PATH via cmux's shell integration (runs after `.zshrc`/`.bashrc`) +- Each invocation gets a fresh UUID session ID +- The `cmux claude-hook` CLI subcommand handles routing hook payloads to the correct workspace +- Session mappings are stored at `~/.cmuxterm/claude-hook-sessions.json` (auto-pruned after 7 days) +- The `Stop` hook reads the Claude session transcript (via `transcript_path`) to include the last assistant message in the completion notification