Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
e619798
Add mobile chat observer regression coverage
azooz2003-bit Jun 29, 2026
cb9601a
Detect Claude mobile chat sessions from process details
azooz2003-bit Jun 29, 2026
4004b39
Await mobile chat observation before listing sessions
azooz2003-bit Jun 30, 2026
bf4d102
Split mobile chat observation scanner files
azooz2003-bit Jun 30, 2026
8f2e200
Merge remote-tracking branch 'origin/main' into feat-ios-claude-gui-d…
azooz2003-bit Jun 30, 2026
e2cef6c
Keep exit liveness matching to agent executables
azooz2003-bit Jun 30, 2026
45ddba9
Make observed agent scan load process details lazily
azooz2003-bit Jun 30, 2026
b0a7edb
Avoid inherited env mobile chat detections
azooz2003-bit Jun 30, 2026
944b3cb
Refresh mobile chat observations on each list pull
azooz2003-bit Jun 30, 2026
2ab6bfd
Avoid unchanged observed session updates
azooz2003-bit Jun 30, 2026
e67ec27
Single-flight mobile chat observations
azooz2003-bit Jun 30, 2026
a7b56bb
Share mobile chat liveness detection
azooz2003-bit Jun 30, 2026
c592ca6
Avoid blocking mobile chat list observation
azooz2003-bit Jun 30, 2026
a63295d
Throttle scheduled mobile chat observation
azooz2003-bit Jun 30, 2026
5b2a221
Use timestamp throttle for mobile chat observation
azooz2003-bit Jun 30, 2026
f8ca5b4
Surface fresh Claude sessions in mobile chat
azooz2003-bit Jun 30, 2026
782228a
Reconcile pending Claude chat sessions
azooz2003-bit Jun 30, 2026
247030a
Split agent chat observe scan helpers
azooz2003-bit Jun 30, 2026
3767d49
Store seeded chat sessions by canonical id
azooz2003-bit Jun 30, 2026
993e0c7
Fix pending Claude lifecycle reconciliation
azooz2003-bit Jun 30, 2026
04e3e36
Notify clients when Claude aliases are removed
azooz2003-bit Jun 30, 2026
640f3c5
Harden pending Claude session removal
azooz2003-bit Jun 30, 2026
d9d58b5
Preserve Claude hook-store lookup aliases
azooz2003-bit Jun 30, 2026
3e8f3ea
Avoid permanent unversioned removal tombstones
azooz2003-bit Jun 30, 2026
95e23f6
Fix stale Claude GUI chat rows
azooz2003-bit Jun 30, 2026
57be801
Make Claude mobile chat detection deterministic
azooz2003-bit Jul 2, 2026
f330d20
Bound mobile chat agent observation
azooz2003-bit Jul 3, 2026
4eb3baa
Fix scoped agent observation reuse
azooz2003-bit Jul 3, 2026
552726f
Coalesce ignored mobile chat refreshes
azooz2003-bit Jul 3, 2026
270f9ec
Preserve pending Claude history identity
azooz2003-bit Jul 3, 2026
ec9cf42
Gate Claude session identity by provenance
azooz2003-bit Jul 3, 2026
80a8db5
Preserve pinned Claude chat during alias handoff
azooz2003-bit Jul 3, 2026
d910ac9
Gate agent chat detection to foreground processes
azooz2003-bit Jul 3, 2026
9dc043b
Drain superseded agent observation waiters
azooz2003-bit Jul 3, 2026
5ca0489
Require authoritative Claude chat identity
azooz2003-bit Jul 3, 2026
277c067
Index live Claude session aliases
azooz2003-bit Jul 3, 2026
dd51e51
Match Claude liveness by session identity
azooz2003-bit Jul 3, 2026
ff0e70f
Isolate settings notification observer tests
azooz2003-bit Jul 3, 2026
6a4cbc4
Harden Claude resume session parsing
azooz2003-bit Jul 3, 2026
7027cdd
Stabilize chat store cache-head test
azooz2003-bit Jul 3, 2026
5c552c3
Harden Claude liveness review paths
azooz2003-bit Jul 3, 2026
ca789c0
Merge remote-tracking branch 'origin/main' into feat-ios-claude-gui-d…
azooz2003-bit Jul 3, 2026
d1c28ce
Preserve launch-kind agent detection
azooz2003-bit Jul 3, 2026
9234eec
Constrain Claude liveness fallback
azooz2003-bit Jul 3, 2026
13bc715
Fix Claude GUI session surface binding
azooz2003-bit Jul 3, 2026
7e84a11
Address Claude GUI detection review issues
azooz2003-bit Jul 3, 2026
9bd543d
Reject inherited Claude launch env for child tools
azooz2003-bit Jul 3, 2026
dcf4f9a
Detect Claude exe runtime process
azooz2003-bit Jul 3, 2026
4a46400
Create pending Claude sessions for unidentified GUI launches
azooz2003-bit Jul 3, 2026
a5190ce
Preserve live agent chat bindings during seed
azooz2003-bit Jul 3, 2026
ce3f092
Cancel superseded agent chat scans
azooz2003-bit Jul 3, 2026
59aa121
Address agent chat policy review fixes
azooz2003-bit Jul 3, 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
16 changes: 8 additions & 8 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23051,14 +23051,14 @@ struct CMUXCLI {
)

var didSendFeedTelemetry = false
func sendClaudeFeedTelemetry(workspaceId: String? = nil) {
func sendClaudeFeedTelemetry(workspaceId: String? = nil, surfaceId: String? = nil) {
didSendFeedTelemetry = true
sendFeedTelemetry(
client: client,
source: "claude",
subcommand: subcommand,
parsedInput: parsedInput,
workspaceId: workspaceId ?? workspaceArg,
workspaceId: workspaceId ?? workspaceArg, surfaceId: surfaceId,
socketPassword: socketPassword
)
}
Expand Down Expand Up @@ -23087,7 +23087,7 @@ struct CMUXCLI {
client: client
)
let surfaceId = resolvedSurface.surfaceId
sendClaudeFeedTelemetry(workspaceId: workspaceId)
sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId)
let claudePid = claudeAgentPID(from: ProcessInfo.processInfo.environment)
let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(
currentAgentPID: claudePid,
Expand Down Expand Up @@ -23223,7 +23223,7 @@ struct CMUXCLI {
currentAgentPID: claudePid,
env: ProcessInfo.processInfo.environment
)
sendClaudeFeedTelemetry(workspaceId: workspaceId)
sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId)

guard shouldApplyClaudeHookVisibleMutation(
sessionStore: sessionStore,
Expand Down Expand Up @@ -23361,7 +23361,7 @@ struct CMUXCLI {
currentAgentPID: claudePid,
env: ProcessInfo.processInfo.environment
)
sendClaudeFeedTelemetry(workspaceId: workspaceId)
sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId)
let shouldApplyPromptSubmit =
shouldApplyClaudeHookVisibleMutation(
sessionStore: sessionStore,
Expand Down Expand Up @@ -23503,7 +23503,6 @@ struct CMUXCLI {
currentAgentPID: claudePid,
env: ProcessInfo.processInfo.environment
)
sendClaudeFeedTelemetry(workspaceId: workspaceId)
let resolvedSurface = try resolvePreferredSurfaceForClaudeHookDetailed(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
Expand All @@ -23513,6 +23512,7 @@ struct CMUXCLI {
client: client
)
let surfaceId = resolvedSurface.surfaceId
sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId)
guard shouldApplyClaudeHookVisibleMutation(
sessionStore: sessionStore,
parsedInput: parsedInput,
Expand Down Expand Up @@ -23725,7 +23725,7 @@ struct CMUXCLI {
surfaceId: consumedSession.surfaceId,
sessionId: consumedSession.sessionId
)
sendClaudeFeedTelemetry(workspaceId: workspaceId)
sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: consumedSession.surfaceId)
let shouldClearVisibleState = shouldApplyClaudeHookVisibleMutation(
sessionStore: sessionStore,
sessionId: consumedSession.sessionId,
Expand Down Expand Up @@ -23784,7 +23784,7 @@ struct CMUXCLI {
client: client
)
let surfaceId = resolvedSurface.surfaceId
sendClaudeFeedTelemetry(workspaceId: workspaceId)
sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId)
let claudePid = mappedSession?.pid ?? claudeAgentPID(from: ProcessInfo.processInfo.environment)
let suppressVisibleMutations = shouldSuppressNestedAgentVisibleMutations(
currentAgentPID: claudePid,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public actor FixtureChatEventSource: ChatEventSource {
break
case .unknown:
break
case .stateChanged, .descriptorChanged:
case .stateChanged, .descriptorChanged, .sessionRemoved:
break
}
for continuation in continuations.values {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,13 @@ public final class ChatConversationStore {
@ObservationIgnored private let now: @Sendable () -> Date
@ObservationIgnored private var pendingCounter = 0
@ObservationIgnored private var isFlushingQueue = false
@ObservationIgnored private var endedByUnversionedRemoval = false
/// True once a queued send has been flushed in the current idle
/// window; cleared when the agent next leaves idle. Ensures queued
/// prompts are delivered ONE per turn (the agent only flips back to
/// .working a round-trip after the first inject, so an ungated loop
/// would dump them all into a still-idle terminal at once).
@ObservationIgnored private var didFlushThisIdleWindow = false

/// Creates a conversation store.
///
/// - Parameters:
Expand Down Expand Up @@ -595,13 +595,13 @@ public final class ChatConversationStore {
}
}
if didChange { reproject() }
case .stateChanged(let state):
agentState = state
case .stateChanged(let state): guard agentState != .ended else { return }; agentState = state
if case .idle = state {} else { didFlushThisIdleWindow = false }
case .descriptorChanged(let descriptor):
self.descriptor = descriptor
agentState = descriptor.state
if case .idle = descriptor.state {} else { didFlushThisIdleWindow = false }
guard descriptor.version > self.descriptor.version || (descriptor.version == self.descriptor.version && (agentState != .ended || endedByUnversionedRemoval)) else { return }; self.descriptor = descriptor; endedByUnversionedRemoval = false
agentState = descriptor.state; if case .idle = descriptor.state {} else { didFlushThisIdleWindow = false }
case .sessionRemoved(let version):
guard version == Int.max || version >= descriptor.version else { return }; let unversioned = version == Int.max; let nextVersion = unversioned ? descriptor.version : max(descriptor.version, version); self.descriptor = descriptor.withState(.ended); self.descriptor.version = nextVersion; agentState = .ended; endedByUnversionedRemoval = unversioned
case .terminalBlocks(let blocks):
// Upsert by id: a new id appends to the order; an existing id
// replaces in place (output grew / command finished). Whole-block
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public struct ChatSessionListReducer: Sendable {
/// The workspace whose sessions the list holds. A `descriptorChanged`
/// for a different workspace is ignored; `nil` accepts every workspace.
public let workspaceID: String?
private var removedVersionBySessionID: [String: Int] = [:]

/// Creates a reducer scoped to one workspace.
///
Expand All @@ -26,12 +27,16 @@ public struct ChatSessionListReducer: Sendable {
/// - frame: The pushed session event.
/// - sessions: The current list.
/// - Returns: The updated list (unchanged for irrelevant frames).
public func applying(
public mutating func applying(
_ frame: ChatSessionEventFrame,
to sessions: [ChatSessionDescriptor]
) -> [ChatSessionDescriptor] {
switch frame.event {
case .descriptorChanged(let descriptor):
if let removedVersion = removedVersionBySessionID[descriptor.id],
descriptor.version <= removedVersion {
return sessions
}
// Out-of-workspace descriptors never enter a scoped list.
if let workspaceID, descriptor.workspaceID != workspaceID {
return sessions
Expand All @@ -55,6 +60,7 @@ public struct ChatSessionListReducer: Sendable {
} else {
updated.append(descriptor)
}
removedVersionBySessionID.removeValue(forKey: descriptor.id)
return updated
case .stateChanged:
// The bare state push carries NO version, so applying it here would
Expand All @@ -67,6 +73,21 @@ public struct ChatSessionListReducer: Sendable {
// conversation's `ChatConversationStore` still consumes `stateChanged`
// directly for its own live state (it is not version-reconciled).
return sessions
case .sessionRemoved(let version):
let currentVersion = sessions.first(where: { $0.id == frame.sessionID })?.version
if let currentVersion, version < currentVersion {
return sessions
}
if version != Int.max {
removedVersionBySessionID[frame.sessionID] = max(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
removedVersionBySessionID[frame.sessionID] ?? 0,
version
)
}
guard currentVersion != nil else {
return sessions
}
return sessions.filter { $0.id != frame.sessionID }
case .appended, .updated, .terminalBlocks, .streamingProse, .reset, .unknown:
// Transcript-content frames don't affect the session list.
return sessions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public enum ChatSessionEvent: Sendable, Equatable {
case stateChanged(ChatAgentState)
/// The session's descriptor changed (title, terminal binding, ...).
case descriptorChanged(ChatSessionDescriptor)
/// The producing host removed this session from its live registry.
case sessionRemoved(version: Int)

/// Terminal command-blocks were appended or updated (terminal-kind
/// sessions). Receivers upsert by ``TerminalCommandBlock/id``; the
Expand Down Expand Up @@ -42,13 +44,15 @@ extension ChatSessionEvent: Codable {
case state
case descriptor
case blocks
case version
}

private enum EventName: String {
case appended
case updated
case stateChanged = "state_changed"
case descriptorChanged = "descriptor_changed"
case sessionRemoved = "session_removed"
case terminalBlocks = "terminal_blocks"
case streamingProse = "streaming_prose"
case reset
Expand All @@ -66,6 +70,8 @@ extension ChatSessionEvent: Codable {
self = .stateChanged(try container.decode(ChatAgentState.self, forKey: .state))
case .descriptorChanged:
self = .descriptorChanged(try container.decode(ChatSessionDescriptor.self, forKey: .descriptor))
case .sessionRemoved:
self = .sessionRemoved(version: try container.decodeIfPresent(Int.self, forKey: .version) ?? Int.max)
Comment thread
azooz2003-bit marked this conversation as resolved.
case .terminalBlocks:
self = .terminalBlocks(try container.decode([TerminalCommandBlock].self, forKey: .blocks))
case .streamingProse:
Expand Down Expand Up @@ -95,6 +101,9 @@ extension ChatSessionEvent: Codable {
case .descriptorChanged(let descriptor):
try container.encode(EventName.descriptorChanged.rawValue, forKey: .event)
try container.encode(descriptor, forKey: .descriptor)
case .sessionRemoved(let version):
try container.encode(EventName.sessionRemoved.rawValue, forKey: .event)
try container.encode(version, forKey: .version)
case .terminalBlocks(let blocks):
try container.encode(EventName.terminalBlocks.rawValue, forKey: .event)
try container.encode(blocks, forKey: .blocks)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import Foundation
import Testing

@testable import CmuxAgentChat

@Suite("ChatConversationStore session removal")
@MainActor
struct ChatConversationStoreSessionRemovalTests {
private static nonisolated let baseTime = Date(timeIntervalSince1970: 1_781_006_400)

@Test("stale sessionRemoved does not end a newer focused descriptor")
func staleSessionRemovedDoesNotEndNewerDescriptor() async {
let source = EventSource()
let store = ChatConversationStore(
descriptor: ChatSessionDescriptor(
id: "session-1",
agentKind: .claude,
title: "Test",
state: .working(since: Self.baseTime),
version: 6
),
source: source,
now: { Self.baseTime }
)
let runTask = Task { await store.run() }
defer { runTask.cancel() }

#expect(await Self.waitUntil { store.isConnected })
await source.emit(.sessionRemoved(version: 5))
await Task.yield()

#expect(store.agentState == .working(since: Self.baseTime))
}

@Test("stale live events do not revive removed focused descriptor")
func staleLiveEventsDoNotReviveRemovedDescriptor() async {
let source = EventSource()
let store = ChatConversationStore(
descriptor: Self.descriptor(state: .working(since: Self.baseTime), version: 5),
source: source,
now: { Self.baseTime }
)
let runTask = Task { await store.run() }
defer { runTask.cancel() }

#expect(await Self.waitUntil { store.isConnected })
await source.emit(.sessionRemoved(version: 6))
#expect(await Self.waitUntil { store.agentState == ChatAgentState.ended })

await source.emit(.stateChanged(.idle))
await source.emit(.descriptorChanged(Self.descriptor(state: .idle, version: 6)))
await Task.yield()
#expect(store.agentState == ChatAgentState.ended)

await source.emit(.descriptorChanged(Self.descriptor(state: .idle, version: 7)))
#expect(await Self.waitUntil { store.agentState == ChatAgentState.idle })
}

@Test("sessionRemoved keeps the public descriptor state in sync")
func sessionRemovedUpdatesPublicDescriptorState() async {
let source = EventSource()
let store = ChatConversationStore(
descriptor: Self.descriptor(state: .working(since: Self.baseTime), version: 5),
source: source,
now: { Self.baseTime }
)
let runTask = Task { await store.run() }
defer { runTask.cancel() }

#expect(await Self.waitUntil { store.isConnected })
await source.emit(.sessionRemoved(version: 6))
#expect(await Self.waitUntil { store.agentState == ChatAgentState.ended })

#expect(store.descriptor.state == .ended)
#expect(store.descriptor.version == 6)
}

@Test("unversioned sessionRemoved allows equal-version descriptor revival")
func unversionedSessionRemovedAllowsEqualVersionDescriptorRevival() async {
let source = EventSource()
let store = ChatConversationStore(
descriptor: Self.descriptor(state: .working(since: Self.baseTime), version: 5),
source: source,
now: { Self.baseTime }
)
let runTask = Task { await store.run() }
defer { runTask.cancel() }

#expect(await Self.waitUntil { store.isConnected })
await source.emit(.sessionRemoved(version: Int.max))
#expect(await Self.waitUntil { store.agentState == ChatAgentState.ended })
#expect(store.descriptor.version == 5)

await source.emit(.descriptorChanged(Self.descriptor(state: .idle, version: 5)))
#expect(await Self.waitUntil { store.agentState == ChatAgentState.idle })
#expect(store.descriptor.version == 5)
}

private static func descriptor(
state: ChatAgentState,
version: Int
) -> ChatSessionDescriptor {
ChatSessionDescriptor(
id: "session-1",
agentKind: .claude,
title: "Test",
state: state,
version: version
)
}

private static func waitUntil(
iterations: Int = 400,
_ condition: () -> Bool
) async -> Bool {
for iteration in 0..<iterations {
if condition() { return true }
await Task.yield()
if iteration % 20 == 19 {
try? await Task.sleep(nanoseconds: 2_000_000)
}
}
return condition()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -968,8 +968,8 @@ struct ChatConversationStoreTests {
let source = TruncatedHeadEventSource(newest: newest)
let store = Self.makeStore(source: source)
let runTask = Task { await store.run() }
defer { runTask.cancel() }
#expect(await TestPoller.waitUntil { store.hasLoadedInitialHistory })
runTask.cancel(); await runTask.value
#expect(store.hasMoreHistory)
#expect(store.historyTruncatedAtHead == false)

Expand Down
Loading
Loading