-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Add iOS agent event feed #10064
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?
Add iOS agent event feed #10064
Changes from 42 commits
5d85918
ee1f48e
116c6ec
df3bfd9
c2fbe65
6798bd4
4901c40
3197de2
0ca2634
8b93516
7cc5c9e
88e3618
c495ccc
e3970fa
b3f3fe6
3ed31ab
c48f236
323941e
6e39466
b3071b2
50bfa2f
c614099
946ff06
11111f1
2b56633
8222297
86db8b1
388ac4c
ba605ff
0138204
d33f276
f687351
1292c7e
abe1523
1d1094a
311491f
339ad64
af4c6db
a9ae014
7813014
821cf79
844297c
21b3fe0
1fa8ca4
d135f50
8ff9a8a
281b2c7
0b08904
7e485f6
354df52
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 |
|---|---|---|
|
|
@@ -79,7 +79,7 @@ struct FeedEventClassifier { | |
| /// Whether any of `source`'s registered events carry the | ||
| /// ``FeedEventSemantic/nativeApprovalPrompt`` semantic. | ||
| private static func sourceRaisesNativeApprovalPrompts(_ source: String) -> Bool { | ||
| feedEventSemanticRegistry[source]?.values.contains(.nativeApprovalPrompt) == true | ||
| feedEventSemanticRegistry[normalizedSource(source)]?.values.contains(.nativeApprovalPrompt) == true | ||
| } | ||
|
|
||
| /// User-attention semantic of a hook/feed event, independent of the | ||
|
|
@@ -92,6 +92,10 @@ struct FeedEventClassifier { | |
| /// Resolved against the tool name so Claude's `ExitPlanMode` / | ||
| /// `AskUserQuestion` approvals route to their dedicated kinds. | ||
| case approvalRequest | ||
| /// A structured user question, boolean confirmation, or elicitation | ||
| /// form. These share the AskUserQuestion wire envelope so older | ||
| /// agents still receive the same reply bridge. | ||
| case questionRequest | ||
| /// A tool is about to run but no approval is pending. Telemetry | ||
| /// only. Used by agents that expose a *separate* approval event | ||
| /// (Claude, Codex, Hermes) so their pre-tool hook never escalates. | ||
|
|
@@ -140,8 +144,61 @@ struct FeedEventClassifier { | |
| source: String, | ||
| event: String | ||
| ) -> FeedEventSemantic { | ||
| let table = feedEventSemanticRegistry[source] ?? telemetryOnlyFeedEventSemantics | ||
| return table[event] ?? .unknown | ||
| let sourceKey = normalizedSource(source) | ||
| let table = feedEventSemanticRegistry[sourceKey] ?? telemetryOnlyFeedEventSemantics | ||
| if let semantic = table[event] { | ||
| return semantic | ||
| } | ||
| // The agent ecosystems do not share one spelling for a structured | ||
| // user-input request. Once a source is registered in the cmux hook | ||
| // catalog, normalize the small, explicit family of question names so | ||
| // new MCP/app-server adapters do not silently become telemetry. This | ||
| // remains fail-closed for unknown sources and for all other unknown | ||
| // event names. | ||
| if registeredFeedSources.contains(sourceKey), isQuestionEventName(event) { | ||
| return .questionRequest | ||
| } | ||
| return .unknown | ||
| } | ||
|
|
||
| private static func normalizedSource(_ source: String) -> String { | ||
| let value = source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| switch value { | ||
| case "claude-code": return "claude" | ||
| case "cursor-agent": return "cursor" | ||
| case "open-code": return "opencode" | ||
| case "gemini-cli": return "gemini" | ||
| case "grok-code": return "grok" | ||
| case "rovo": return "rovodev" | ||
| case "agy": return "antigravity" | ||
| default: return value | ||
| } | ||
| } | ||
|
|
||
| private static func isQuestionEventName(_ event: String) -> Bool { | ||
| let normalized = event.unicodeScalars.filter { CharacterSet.alphanumerics.contains($0) } | ||
| .map(String.init) | ||
| .joined() | ||
| .lowercased() | ||
| return [ | ||
| "askuserquestion", | ||
| "askuserconfirmation", | ||
| "askuser", | ||
| "booleanquestion", | ||
| "confirmationrequest", | ||
| "questionasked", | ||
| "questionv2asked", | ||
| "questionrequest", | ||
| "elicitation", | ||
| "elicitationrequest", | ||
| "mcpelicitation", | ||
| "mcpserverelicitationrequest", | ||
| "requestuserinput", | ||
| "userinputrequest", | ||
| "inputrequest", | ||
| "toolrequestuserinput", | ||
| "itemtoolrequestuserinput", | ||
| ].contains(normalized) | ||
|
Comment on lines
+185
to
+208
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. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win One question-event spelling table, defined twice, already divergent. Both sites normalize an event name by stripping non-alphanumerics and lowercasing, then match a hardcoded list to decide whether the event is a structured user question. The lists disagree, so the same wire value classifies differently depending on which path an event takes.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /// Tool names that carry their own dedicated approval wire event rather | ||
|
|
@@ -166,6 +223,8 @@ struct FeedEventClassifier { | |
| switch semantic { | ||
| case .approvalRequest: | ||
| return actionable(dedicatedApprovalEvent(for: toolName) ?? "PermissionRequest") | ||
| case .questionRequest: | ||
| return actionable("AskUserQuestion") | ||
| case .toolStartMaybeApproval: | ||
| if let dedicated = dedicatedApprovalEvent(for: toolName) { | ||
| return actionable(dedicated) | ||
|
|
@@ -271,6 +330,11 @@ struct FeedEventClassifier { | |
| "SubagentStart": .subagentStart, | ||
| "SubagentStop": .subagentResponse, | ||
| "Notification": .statusNotification, | ||
| "AskUserQuestion": .questionRequest, | ||
| "AskUserConfirmation": .questionRequest, | ||
| "BooleanQuestion": .questionRequest, | ||
| "Elicitation": .questionRequest, | ||
| "ElicitationRequest": .questionRequest, | ||
| ], | ||
| "codex": [ | ||
| // Codex runs PermissionRequest hooks before its own approval | ||
|
|
@@ -303,6 +367,13 @@ struct FeedEventClassifier { | |
| "subagent_stop": .subagentResponse, | ||
| "Notification": .statusNotification, | ||
| "notification": .statusNotification, | ||
| "AskUserQuestion": .questionRequest, | ||
| "AskUserConfirmation": .questionRequest, | ||
| "BooleanQuestion": .questionRequest, | ||
| "Elicitation": .questionRequest, | ||
| "ElicitationRequest": .questionRequest, | ||
| "tool/requestUserInput": .questionRequest, | ||
| "requestUserInput": .questionRequest, | ||
| ], | ||
| "hermes-agent": [ | ||
| // `pre_tool_call` is a tool *starting* — Hermes raises a | ||
|
|
@@ -321,6 +392,9 @@ struct FeedEventClassifier { | |
| "on_session_reset": .sessionStart, | ||
| "on_session_end": .sessionEnd, | ||
| "on_session_finalize": .sessionEnd, | ||
| "ask_user": .questionRequest, | ||
| "question": .questionRequest, | ||
| "elicitation": .questionRequest, | ||
| ], | ||
| // Gemini CLI consumes the generic PreToolUse decision schema and has | ||
| // no separate approval event, so it deliberately opts in to blocking. | ||
|
|
@@ -336,6 +410,8 @@ struct FeedEventClassifier { | |
| "userPromptSubmit": .promptSubmit, | ||
| "agentSpawn": .sessionStart, | ||
| "stop": .response, | ||
| "askUserQuestion": .questionRequest, | ||
| "ask_user": .questionRequest, | ||
| ], | ||
| ] | ||
|
|
||
|
|
@@ -356,6 +432,13 @@ struct FeedEventClassifier { | |
| "SubagentStart": .subagentStart, | ||
| "SubagentStop": .subagentResponse, | ||
| "Notification": .statusNotification, | ||
| "AskUserQuestion": .questionRequest, | ||
| "AskUserConfirmation": .questionRequest, | ||
| "BooleanQuestion": .questionRequest, | ||
| "Elicitation": .questionRequest, | ||
| "ElicitationRequest": .questionRequest, | ||
| "tool/requestUserInput": .questionRequest, | ||
| "requestUserInput": .questionRequest, | ||
| ] | ||
|
|
||
| /// Safe fallback for unregistered sources. Familiar event names preserve | ||
|
|
@@ -376,6 +459,15 @@ struct FeedEventClassifier { | |
| "Notification": .statusNotification, | ||
| ] | ||
|
|
||
| /// Sources with an installed cmux hook integration. They may opt into the | ||
| /// explicit question-name normalization above; unknown integrations stay | ||
| /// telemetry-only until their wire contract is reviewed. | ||
| private static let registeredFeedSources: Set<String> = [ | ||
| "claude", "codex", "opencode", "grok", "pi", "omp", "campfire", "amp", | ||
| "cursor", "gemini", "kiro", "antigravity", "rovodev", "hermes-agent", | ||
| "copilot", "codebuddy", "factory", "qoder", "kimi", | ||
| ] | ||
|
|
||
| /// Tools that mutate state and deserve a user-visible approve/ | ||
| /// deny prompt in Feed. Keyed on the canonical tool names Claude, | ||
| /// Codex, and similar agents emit. Read-only tools (Read, Grep, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| /// Workstream DTOs live in the lower shell-model package so aggregation and | ||
| /// RPC decoding share one schema without a dependency cycle. | ||
| public import CmuxMobileShellModel |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import Foundation | ||
|
|
||
| /// Account/team-scoped, bounded disk cache for authenticated Feed snapshots. | ||
| actor AgentFeedCacheStore { | ||
| private let directory: URL | ||
| private let fileManager: FileManager | ||
| private let maxMacCount = 20 | ||
|
|
||
| init(directory: URL? = nil, fileManager: FileManager = .default) { | ||
| self.fileManager = fileManager | ||
| self.directory = directory | ||
| ?? URL.cachesDirectory.appending(path: "AgentFeed", directoryHint: .isDirectory) | ||
| } | ||
|
|
||
| func load(scopeKey: String) -> [AgentFeedCachedSnapshot] { | ||
| guard let data = try? Data(contentsOf: fileURL(scopeKey: scopeKey)), | ||
| let snapshots = try? JSONDecoder().decode([AgentFeedCachedSnapshot].self, from: data) else { | ||
| return [] | ||
| } | ||
| return Array(snapshots.sorted { $0.cachedAt > $1.cachedAt }.prefix(maxMacCount)) | ||
| } | ||
|
|
||
| func upsert(_ snapshot: AgentFeedCachedSnapshot, scopeKey: String) { | ||
| var snapshots = load(scopeKey: scopeKey) | ||
| snapshots.removeAll { $0.ownerKey == snapshot.ownerKey } | ||
| snapshots.insert(snapshot, at: 0) | ||
| do { | ||
| try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) | ||
| try JSONEncoder().encode(Array(snapshots.prefix(maxMacCount))) | ||
| .write(to: fileURL(scopeKey: scopeKey), options: .atomic) | ||
| } catch { | ||
| // Cache failure is non-fatal; the authenticated host remains authoritative. | ||
| } | ||
| } | ||
|
|
||
| func clear(scopeKey: String) { | ||
| try? fileManager.removeItem(at: fileURL(scopeKey: scopeKey)) | ||
| } | ||
|
|
||
| func clearAll() { | ||
| try? fileManager.removeItem(at: directory) | ||
| } | ||
|
|
||
| private func fileURL(scopeKey: String) -> URL { | ||
| let filename = Data(scopeKey.utf8).base64EncodedString() | ||
| .replacingOccurrences(of: "/", with: "_") | ||
| .replacingOccurrences(of: "+", with: "-") | ||
| .replacingOccurrences(of: "=", with: "") | ||
| return directory.appending(path: "\(filename).json") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import Foundation | ||
|
|
||
| /// Authenticated host response retained for offline Feed restoration. | ||
| struct AgentFeedCachedSnapshot: Codable, Sendable { | ||
| let ownerKey: String | ||
| let macDeviceID: String | ||
| let instanceTag: String? | ||
| let displayName: String | ||
| let responseData: Data | ||
| let cachedAt: Date | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import CmuxMobileShellModel | ||
|
|
||
| /// Last authoritative workstream snapshot received from one Mac. | ||
| struct AgentFeedMacSnapshot { | ||
| var pages: MobileAgentFeedPageAccumulator | ||
| var items: [MobileAgentFeedItem] | ||
|
|
||
| var revision: UInt64 { pages.revision } | ||
| var nextCursor: String? { pages.nextCursor } | ||
| var hasMore: Bool { pages.hasMore } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.