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
This commit is contained in:
Lawrence Chen 2026-02-15 18:33:36 -08:00
parent f789306a97
commit 777d6b048e
10 changed files with 974 additions and 262 deletions

View file

@ -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<T>(_ 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 <session-start|stop|notification> [--workspace <id|index>] [--surface <id|index>]
"""
)
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[..<index]) + ""
}
private func sanitizeNotificationField(_ value: String) -> 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 <text> [--subtitle <text>] [--body <text>] [--workspace <id|index>] [--surface <id|index>]
list-notifications
clear-notifications
claude-hook <session-start|stop|notification> [--workspace <id|index>] [--surface <id|index>]
set-app-focus <active|inactive|clear>
simulate-app-active

View file

@ -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 = "<group>"; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>";

View file

@ -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() {

41
Resources/bin/claude Executable file
View file

@ -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" "$@"

View file

@ -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

View file

@ -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

View file

@ -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) }

View file

@ -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:

View file

@ -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

View file

@ -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.
<Callout type="info">
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.
</Callout>
## 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.
<Callout type="info">
You can also set `CMUX_CLAUDE_HOOKS_DISABLED=1` in your shell environment to disable per-terminal.
</Callout>
## 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