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:
parent
f789306a97
commit
777d6b048e
10 changed files with 974 additions and 262 deletions
595
CLI/cmux.swift
595
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<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>";
|
||||
|
|
|
|||
|
|
@ -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
41
Resources/bin/claude
Executable 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" "$@"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue