diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 09fd854a48c..867d521716c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1299,6 +1299,70 @@ struct CMUXCLI { let response = try sendV1Command("clear_notifications", client: client) print(response) + case "wait": + let timeoutStr = optionValue(commandArgs, name: "--timeout") + let batchStr = optionValue(commandArgs, name: "--batch") + let wsFlag = optionValue(commandArgs, name: "--workspace") + let includeSelf = hasFlag(commandArgs, name: "--include-self") + + let overallTimeout: Double = { + guard let s = timeoutStr, let v = Double(s), v > 0 else { return 0 } + return v + }() + let batchMs: Int = { + guard let s = batchStr, let v = Int(s) else { return 200 } + return max(0, v) + }() + + let workspaceId: String? = wsFlag != nil ? try resolveWorkspaceId(wsFlag, client: client) : nil + let callerSurfaceId: String? = includeSelf ? nil : ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + + // Install SIGINT handler to print [] and exit cleanly + signal(SIGINT) { _ in + print("[]") + exit(0) + } + + let overallDeadline: Date? = overallTimeout > 0 ? Date().addingTimeInterval(overallTimeout) : nil + + while true { + // Calculate server-side poll time (10s default, clamped to remaining time) + var pollMs = 10000 + if let deadline = overallDeadline { + let remaining = deadline.timeIntervalSinceNow + if remaining <= 0 { + print("[]") + break + } + pollMs = min(pollMs, max(100, Int(remaining * 1000))) + } + + var params: [String: Any] = [ + "poll_timeout_ms": pollMs, + "batch_ms": batchMs, + ] + if let ws = workspaceId { + params["workspace_id"] = ws + } + if let sf = callerSurfaceId { + params["exclude_surface_id"] = sf + } + + let result = try client.sendV2(method: "notification.wait", params: params) + let timedOut = result["timed_out"] as? Bool ?? true + + if !timedOut, let notifications = result["notifications"] as? [[String: Any]], !notifications.isEmpty { + print(jsonString(notifications)) + break + } + + // Server lease timed out, retry unless overall deadline reached + if let deadline = overallDeadline, Date() >= deadline { + print("[]") + break + } + } + case "claude-hook": cliTelemetry.breadcrumb("claude-hook.dispatch") do { @@ -4183,6 +4247,28 @@ struct CMUXCLI { Clear all queued notifications. """ + case "wait": + return """ + Usage: cmux wait [--timeout ] [--batch ] [--workspace ] [--include-self] + + Block until a new notification arrives. Prints a JSON array of notifications + and exits with code 0. If --timeout is reached with no notifications, prints + an empty array ([]) and exits with code 0. + + Flags: + --timeout Overall wait timeout (default: 0 = wait forever) + --batch After first notification, wait this long to collect + more before returning (default: 200) + --workspace Only wait for notifications in this workspace + --include-self Include notifications from the caller's own surface + (by default, notifications from $CMUX_SURFACE_ID + are excluded) + + Examples: + cmux wait --timeout 30 + cmux wait --workspace workspace:1 --timeout 60 + cmux wait --timeout 10 --include-self + """ case "set-status": return """ Usage: cmux set-status [flags] @@ -6277,6 +6363,7 @@ struct CMUXCLI { notify --title [--subtitle ] [--body ] [--workspace ] [--surface ] list-notifications clear-notifications + wait [--timeout ] [--batch ] [--workspace ] [--include-self] claude-hook [--workspace ] [--surface ] # sidebar metadata commands diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9163d2089ec..36c9f637570 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1157,6 +1157,8 @@ class TerminalController { return v2Ok(id: id, result: self.v2NotificationList()) case "notification.clear": return v2Result(id: id, self.v2NotificationClear()) + case "notification.wait": + return v2Result(id: id, self.v2NotificationWait(params: params)) // App focus case "app.focus_override.set": @@ -1473,6 +1475,7 @@ class TerminalController { "notification.create_for_target", "notification.list", "notification.clear", + "notification.wait", "app.focus_override.set", "app.simulate_active", "browser.open_split", @@ -4708,6 +4711,98 @@ class TerminalController { return .ok([:]) } + private func v2NotificationWait(params: [String: Any]) -> V2CallResult { + let pollTimeoutMs = min(max(v2Int(params, "poll_timeout_ms") ?? 10000, 100), 12000) + let batchMs = min(max(v2Int(params, "batch_ms") ?? 200, 0), 5000) + let workspaceFilter = v2UUID(params, "workspace_id") + let excludeSurface = v2UUID(params, "exclude_surface_id") + + // Snapshot current notification IDs + let existingIds: Set = DispatchQueue.main.sync { + Set(TerminalNotificationStore.shared.notifications.map { $0.id }) + } + + let pollInterval: TimeInterval = 0.05 + let deadline = Date().addingTimeInterval(Double(pollTimeoutMs) / 1000.0) + var firstSeen: Date? + + while Date() < deadline { + let newNotifications: [TerminalNotification] = DispatchQueue.main.sync { + TerminalNotificationStore.shared.notifications.filter { n in + guard !existingIds.contains(n.id) else { return false } + if let ws = workspaceFilter, n.tabId != ws { return false } + if let ex = excludeSurface, n.surfaceId == ex { return false } + return true + } + } + + if !newNotifications.isEmpty { + if firstSeen == nil { + firstSeen = Date() + } + // Continue polling for batch_ms to collect more + if Date().timeIntervalSince(firstSeen!) >= Double(batchMs) / 1000.0 { + // Mark returned notifications as read and build response + let items: [[String: Any]] = DispatchQueue.main.sync { + let ids = newNotifications.map { $0.id } + for id in ids { + TerminalNotificationStore.shared.markRead(id: id) + } + let formatter = ISO8601DateFormatter() + return newNotifications.map { n in + [ + "id": n.id.uuidString, + "workspace_id": n.tabId.uuidString, + "surface_id": v2OrNull(n.surfaceId?.uuidString), + "title": n.title, + "subtitle": n.subtitle, + "body": n.body, + "created_at": formatter.string(from: n.createdAt), + "is_read": true, + ] + } + } + return .ok(["notifications": items, "timed_out": false]) + } + } + + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(pollInterval)) + } + + // If we collected notifications during the batch window but hit the poll deadline + if firstSeen != nil { + let finalNotifications: [[String: Any]] = DispatchQueue.main.sync { + let current = TerminalNotificationStore.shared.notifications.filter { n in + guard !existingIds.contains(n.id) else { return false } + if let ws = workspaceFilter, n.tabId != ws { return false } + if let ex = excludeSurface, n.surfaceId == ex { return false } + return true + } + let formatter = ISO8601DateFormatter() + for n in current { + TerminalNotificationStore.shared.markRead(id: n.id) + } + return current.map { n in + [ + "id": n.id.uuidString, + "workspace_id": n.tabId.uuidString, + "surface_id": v2OrNull(n.surfaceId?.uuidString), + "title": n.title, + "subtitle": n.subtitle, + "body": n.body, + "created_at": formatter.string(from: n.createdAt), + "is_read": true, + ] + } + } + if !finalNotifications.isEmpty { + return .ok(["notifications": finalNotifications, "timed_out": false]) + } + } + + return .ok(["notifications": [] as [Any], "timed_out": true]) + } + // MARK: - V2 App Focus Methods private func v2AppFocusOverride(params: [String: Any]) -> V2CallResult {