Skip to content
Open
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
5d85918
Add iOS coding agent feed
azooz2003-bit Aug 10, 2026
ee1f48e
Persist scoped agent feed snapshots
azooz2003-bit Aug 10, 2026
116c6ec
Isolate feed cache across teams
azooz2003-bit Aug 10, 2026
df3bfd9
Represent agent feed fetch failures
azooz2003-bit Aug 10, 2026
c2fbe65
Model agent feed helpers as values
azooz2003-bit Aug 10, 2026
6798bd4
Avoid optimizer crash in feed helpers
azooz2003-bit Aug 10, 2026
4901c40
Avoid actor setter reabstraction crash
azooz2003-bit Aug 10, 2026
3197de2
Expand agent feed verification scenarios
azooz2003-bit Aug 10, 2026
0ca2634
Fix feed localization and adaptive interactions
azooz2003-bit Aug 10, 2026
8b93516
Use controlled feed disclosure rows
azooz2003-bit Aug 10, 2026
7cc5c9e
Keep feed action accessibility IDs distinct
azooz2003-bit Aug 10, 2026
88e3618
Fix feed disclosure accessibility ownership
azooz2003-bit Aug 10, 2026
c495ccc
Add persisted agent feed paging
azooz2003-bit Aug 10, 2026
e3970fa
Stop agent feed paging at retention limit
azooz2003-bit Aug 10, 2026
b3f3fe6
Stabilize agent feed performance probe cadence
azooz2003-bit Aug 10, 2026
3ed31ab
Enforce agent feed performance thresholds
azooz2003-bit Aug 10, 2026
c48f236
Stabilize hosted agent feed UI coverage
azooz2003-bit Aug 10, 2026
323941e
Fix feed UI verification contracts
azooz2003-bit Aug 10, 2026
6e39466
Secure feed mutations and history
azooz2003-bit Aug 10, 2026
b3071b2
Serialize feed history persistence
azooz2003-bit Aug 10, 2026
50bfa2f
Harden agent feed refresh lifecycle
azooz2003-bit Aug 10, 2026
c614099
Tighten feed UI state and copy
azooz2003-bit Aug 10, 2026
946ff06
Inject agent feed localization
azooz2003-bit Aug 10, 2026
11111f1
Fix async feed revision locking
azooz2003-bit Aug 10, 2026
2b56633
Stabilize agent feed UI verification
azooz2003-bit Aug 10, 2026
8222297
Preserve agent feed viewport on bursts
azooz2003-bit Aug 10, 2026
86db8b1
Fix off-top agent feed burst anchoring
azooz2003-bit Aug 10, 2026
388ac4c
Preserve feed viewport across source commits
azooz2003-bit Aug 10, 2026
ba605ff
Instrument feed viewport lifecycle
azooz2003-bit Aug 10, 2026
0138204
Hold feed anchor across incremental bursts
azooz2003-bit Aug 10, 2026
d33f276
Close agent feed verification gaps
azooz2003-bit Aug 10, 2026
f687351
Align feed frame sample assertion
azooz2003-bit Aug 10, 2026
1292c7e
test: cover restored pending feed history
azooz2003-bit Aug 10, 2026
abe1523
fix: expire restored feed history
azooz2003-bit Aug 10, 2026
1d1094a
Merge remote-tracking branch 'origin/main' into task-ios-agent-feed
azooz2003-bit Aug 12, 2026
311491f
test: keep completed agent turns in Feed input
azooz2003-bit Aug 12, 2026
339ad64
feat: add switchable iOS agent Feed designs
azooz2003-bit Aug 12, 2026
af4c6db
test: verify resolved Feed items remain browseable
azooz2003-bit Aug 12, 2026
a9ae014
fix: reconcile Feed after empty filters
azooz2003-bit Aug 12, 2026
7813014
Add iOS agent event feed
azooz2003-bit Aug 13, 2026
821cf79
Scope agent Feed key helper
azooz2003-bit Aug 13, 2026
844297c
Merge origin/main into feat-ios-agent-feed-v2
azooz2003-bit Aug 13, 2026
21b3fe0
Route Codex app-server approvals through Feed
azooz2003-bit Aug 13, 2026
1fa8ca4
Harden agent feed lifecycle and MCP responses
azooz2003-bit Aug 13, 2026
d135f50
Preserve completed agent answers in mobile Feed
azooz2003-bit Aug 13, 2026
8ff9a8a
Clarify stale Feed requests on mobile
azooz2003-bit Aug 13, 2026
281b2c7
Fix MCP schema type parsing
azooz2003-bit Aug 13, 2026
0b08904
Break up Feed answer decoding expression
azooz2003-bit Aug 13, 2026
7e485f6
Fix Feed canonical device ID import
azooz2003-bit Aug 13, 2026
354df52
Complete Feed iOS shell integration
azooz2003-bit Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 95 additions & 3 deletions CLI/FeedEventClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

  • CLI/FeedEventClassifier.swift#L178-L201: replace isQuestionEventName with a call into the shared spelling table; this copy uniquely contains confirmationrequest and questionrequest and omits question.
  • Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift#L616-L640: replace isQuestionEvent with the same shared call; this copy uniquely contains question and omits confirmationrequest and questionrequest.
📍 Affects 2 files
  • CLI/FeedEventClassifier.swift#L178-L201 (this comment)
  • Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift#L616-L640
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLI/FeedEventClassifier.swift` around lines 178 - 201, The question-event
spelling table is duplicated and divergent, causing inconsistent classification.
In CLI/FeedEventClassifier.swift lines 178-201, replace isQuestionEventName with
the shared spelling-table call; in
Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift
lines 616-640, replace isQuestionEvent with that same shared call, preserving
the existing normalization behavior and using one canonical list.

}

/// Tool names that carry their own dedicated approval wire event rather
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -336,6 +410,8 @@ struct FeedEventClassifier {
"userPromptSubmit": .promptSubmit,
"agentSpawn": .sessionStart,
"stop": .response,
"askUserQuestion": .questionRequest,
"ask_user": .questionRequest,
],
]

Expand All @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -731,12 +731,18 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
"mobile.events.probe":
return false
case "notification.feed.list", "notification.feed.mark_read", "notification.feed.mark_unread",
"notification.feed.mark_all_read":
"notification.feed.mark_all_read", "workstream.feed.list":
// Feed authority is the authenticated account/peer connection, not
// a workspace-selection ticket. Omit an irrelevant scoped attach
// token so legacy pairings cannot accidentally narrow the global
// feed; Stack auth is still attached to every TCP request.
return true
case "workstream.feed.action", "workstream.feed.reply":
return !ticketCoverage.ticketCoversTerminalRequest(
ticket: ticket,
workspaceSelection: workspaceSelection.value,
terminalSelection: terminalSelection.value
)
case "mobile.browser.list", "mobile.browser.create":
return !ticketCoverage.ticketCoversWorkspaceRequest(
ticket: ticket,
Expand Down
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
Expand Up @@ -9,6 +9,7 @@ import Testing
"notification.feed.mark_read",
"notification.feed.mark_unread",
"notification.feed.mark_all_read",
"workstream.feed.list",
])
func feedRequestsUseAccountAuthorizationWithoutWorkspaceTicketScope(method: String) async throws {
let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 58_465)
Expand All @@ -32,9 +33,14 @@ import Testing
ticket: ticket,
allowsStackAuthFallback: true
)
let params: [String: Any] = ["notification.feed.mark_read", "notification.feed.mark_unread"].contains(method)
? ["notification_ids": ["notification"]]
: [:]
let params: [String: Any]
if ["notification.feed.mark_read", "notification.feed.mark_unread"].contains(method) {
params = ["notification_ids": ["notification"]]
} else if method == "workstream.feed.list" {
params = ["cursor": "00000000-0000-0000-0000-000000000300"]
} else {
params = [:]
}
let request = try MobileCoreRPCClient.requestData(method: method, params: params)

let task = Task { try await client.sendRequest(request) }
Expand All @@ -47,5 +53,46 @@ import Testing
#expect(frame.attachToken == nil)
#expect(frame.stackAccessToken == "test-stack-token")
#expect(frame.hasAuth)
if method == "workstream.feed.list" {
#expect(frame.cursor == "00000000-0000-0000-0000-000000000300")
}
}

@Test(arguments: ["workstream.feed.action", "workstream.feed.reply"])
func feedMutationsPreserveMatchingScopedTicket(method: String) async throws {
let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 58_465)
let transport = QueuedCancellationProbeTransport()
let runtime = TestMobileSyncRuntime(
transportFactory: QueuedCancellationProbeTransportFactory(transport: transport),
stackAccessToken: "test-stack-token"
)
let ticket = try CmxAttachTicket(
workspaceID: "workspace-main",
terminalID: "surface-main",
macDeviceID: "test-mac",
macDisplayName: "Test Mac",
routes: [route],
expiresAt: Date().addingTimeInterval(60),
authToken: "ticket-secret"
)
let client = MobileCoreRPCClient(
runtime: runtime,
route: route,
ticket: ticket,
allowsStackAuthFallback: true
)
let request = try MobileCoreRPCClient.requestData(method: method, params: [
"workspace_id": "workspace-main",
"surface_id": "surface-main",
])

let task = Task { try await client.sendRequest(request) }
let sent = try await transport.waitForSentRequestCount(1)
task.cancel()
_ = try? await task.value

let frame = try #require(sent.first)
#expect(frame.attachToken == "ticket-secret")
#expect(frame.stackAccessToken == "test-stack-token")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ struct RecordedRPCRequest: Sendable {
var workspaceID: String?
var terminalID: String?
var text: String?
var cursor: String?
var hasAuth: Bool
var attachToken: String?
var stackAccessToken: String?
Expand All @@ -88,6 +89,7 @@ func recordedRPCRequest(from payload: Data) throws -> RecordedRPCRequest {
workspaceID: params["workspace_id"] as? String,
terminalID: params["terminal_id"] as? String ?? params["surface_id"] as? String,
text: params["text"] as? String,
cursor: params["cursor"] as? String,
hasAuth: auth != nil,
attachToken: auth?["attach_token"] as? String,
stackAccessToken: auth?["stack_access_token"] as? String
Expand Down
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 }
}
Loading