-
Notifications
You must be signed in to change notification settings - Fork 0
Add cmux wait — block until notification arrives
#1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
Comment on lines
+1321
to
+1324
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "cmux.swift" -type fRepository: LegendsOfLearning/cmux Length of output: 82 🏁 Script executed: # Find the file and examine the context around lines 1321-1324
if [ -f "CLI/cmux.swift" ]; then
sed -n '1310,1330p' CLI/cmux.swift | cat -n
fiRepository: LegendsOfLearning/cmux Length of output: 1069 🌐 Web query:
💡 Result: In a POSIX/Darwin Is Swift
|
||
|
|
||
| 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 <seconds>] [--batch <ms>] [--workspace <id|ref>] [--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 <seconds> Overall wait timeout (default: 0 = wait forever) | ||
| --batch <ms> After first notification, wait this long to collect | ||
| more before returning (default: 200) | ||
| --workspace <id|ref> Only wait for notifications in this workspace | ||
| --include-self Include notifications from the caller's own surface | ||
|
Comment on lines
+4252
to
+4263
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update Line [4252], Line [4262], and Line [6366] under-document accepted workspace formats. The command accepts index too, not just id/ref. Suggested doc fix- Usage: cmux wait [--timeout <seconds>] [--batch <ms>] [--workspace <id|ref>] [--include-self]
+ Usage: cmux wait [--timeout <seconds>] [--batch <ms>] [--workspace <id|ref|index>] [--include-self]
@@
- --workspace <id|ref> Only wait for notifications in this workspace
+ --workspace <id|ref|index> Only wait for notifications in this workspace
@@
- wait [--timeout <seconds>] [--batch <ms>] [--workspace <id|ref>] [--include-self]
+ wait [--timeout <seconds>] [--batch <ms>] [--workspace <id|ref|index>] [--include-self]Also applies to: 6366-6366 🤖 Prompt for AI Agents |
||
| (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 <key> <value> [flags] | ||
|
|
@@ -6277,6 +6363,7 @@ struct CMUXCLI { | |
| notify --title <text> [--subtitle <text>] [--body <text>] [--workspace <id|ref>] [--surface <id|ref>] | ||
| list-notifications | ||
| clear-notifications | ||
| wait [--timeout <seconds>] [--batch <ms>] [--workspace <id|ref>] [--include-self] | ||
| claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>] | ||
|
|
||
| # sidebar metadata commands | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
Comment on lines
+4717
to
+4718
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate filter IDs instead of silently widening the query scope. On Line 4717 and Line 4718, invalid 🔧 Proposed fix 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")
+ if params["workspace_id"] != nil && workspaceFilter == nil {
+ return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
+ }
+ if params["exclude_surface_id"] != nil && excludeSurface == nil {
+ return .err(code: "invalid_params", message: "Missing or invalid exclude_surface_id", data: nil)
+ }🤖 Prompt for AI Agents |
||
|
|
||
| // Snapshot current notification IDs | ||
| let existingIds: Set<UUID> = 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 { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate
--timeout/--batchstrictly to avoid silent infinite waits.On Line [1309] and Line [1313], invalid numeric input silently changes behavior (
--timeout abcbecomes wait-forever). This is a high-impact CLI correctness issue.Suggested fix
🤖 Prompt for AI Agents