diff --git a/CLI/cmux.swift b/CLI/cmux.swift index df77b329565f..4c0201eb2e05 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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 ) } @@ -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, @@ -23223,7 +23223,7 @@ struct CMUXCLI { currentAgentPID: claudePid, env: ProcessInfo.processInfo.environment ) - sendClaudeFeedTelemetry(workspaceId: workspaceId) + sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) guard shouldApplyClaudeHookVisibleMutation( sessionStore: sessionStore, @@ -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, @@ -23503,7 +23503,6 @@ struct CMUXCLI { currentAgentPID: claudePid, env: ProcessInfo.processInfo.environment ) - sendClaudeFeedTelemetry(workspaceId: workspaceId) let resolvedSurface = try resolvePreferredSurfaceForClaudeHookDetailed( preferred: mappedSession?.surfaceId, fallback: surfaceArg, @@ -23513,6 +23512,7 @@ struct CMUXCLI { client: client ) let surfaceId = resolvedSurface.surfaceId + sendClaudeFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) guard shouldApplyClaudeHookVisibleMutation( sessionStore: sessionStore, parsedInput: parsedInput, @@ -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, @@ -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, diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/FixtureChatEventSource.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/FixtureChatEventSource.swift index 478f76c98cd8..5c2be458d294 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/FixtureChatEventSource.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/FixtureChatEventSource.swift @@ -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 { diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift index 61c0821ea0ce..840774e79ca3 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift @@ -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: @@ -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 diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift index c23db35bc47f..2bef7bd4c54d 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift @@ -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. /// @@ -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 @@ -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 @@ -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( + 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 diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Wire/ChatSessionEvent.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Wire/ChatSessionEvent.swift index e9fa75ef80ca..16d84491d8d2 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Wire/ChatSessionEvent.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Wire/ChatSessionEvent.swift @@ -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 @@ -42,6 +44,7 @@ extension ChatSessionEvent: Codable { case state case descriptor case blocks + case version } private enum EventName: String { @@ -49,6 +52,7 @@ extension ChatSessionEvent: Codable { case updated case stateChanged = "state_changed" case descriptorChanged = "descriptor_changed" + case sessionRemoved = "session_removed" case terminalBlocks = "terminal_blocks" case streamingProse = "streaming_prose" case reset @@ -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) case .terminalBlocks: self = .terminalBlocks(try container.decode([TerminalCommandBlock].self, forKey: .blocks)) case .streamingProse: @@ -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) diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreSessionRemovalTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreSessionRemovalTests.swift new file mode 100644 index 000000000000..29b82c20dc08 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreSessionRemovalTests.swift @@ -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.. ChatSessionDescriptor { ChatSessionDescriptor( id: id, agentKind: .claude, workspaceID: workspace, - terminalID: id, state: state + terminalID: id, state: state, version: version ) } @Test("a descriptorChanged for a new session is appended (toggle appears live)") func appendsNewSession() { - let reducer = ChatSessionListReducer(workspaceID: "ws-1") + var reducer = ChatSessionListReducer(workspaceID: "ws-1") let frame = ChatSessionEventFrame( sessionID: "s1", event: .descriptorChanged(descriptor("s1")) ) @@ -32,7 +33,7 @@ struct ChatSessionListReducerTests { @Test("a descriptorChanged for an existing session replaces it in place") func replacesExisting() { - let reducer = ChatSessionListReducer(workspaceID: "ws-1") + var reducer = ChatSessionListReducer(workspaceID: "ws-1") let seed = [descriptor("s1", state: Self.working), descriptor("s2", state: .idle)] let frame = ChatSessionEventFrame( sessionID: "s1", event: .descriptorChanged(descriptor("s1", state: .needsInput(since: Self.t0))) @@ -44,7 +45,7 @@ struct ChatSessionListReducerTests { @Test("a descriptorChanged for another workspace is ignored") func ignoresOtherWorkspace() { - let reducer = ChatSessionListReducer(workspaceID: "ws-1") + var reducer = ChatSessionListReducer(workspaceID: "ws-1") let frame = ChatSessionEventFrame( sessionID: "s9", event: .descriptorChanged(descriptor("s9", workspace: "ws-2")) ) @@ -53,7 +54,7 @@ struct ChatSessionListReducerTests { @Test("a nil-workspace reducer accepts every workspace") func nilWorkspaceAcceptsAll() { - let reducer = ChatSessionListReducer(workspaceID: nil) + var reducer = ChatSessionListReducer(workspaceID: nil) let frame = ChatSessionEventFrame( sessionID: "s9", event: .descriptorChanged(descriptor("s9", workspace: "ws-2")) ) @@ -62,7 +63,7 @@ struct ChatSessionListReducerTests { @Test("an unversioned stateChanged never mutates the list (descriptorChanged is authoritative)") func stateChangedIsNoOpForList() { - let reducer = ChatSessionListReducer(workspaceID: "ws-1") + var reducer = ChatSessionListReducer(workspaceID: "ws-1") let seed = [descriptor("s1", state: Self.working)] // The host pairs every transition with a versioned descriptorChanged, so // the bare stateChanged must not touch the list (it carries no version @@ -73,7 +74,7 @@ struct ChatSessionListReducerTests { @Test("a reordered stateChanged cannot regress newer descriptor state (clobber guard)") func stateChangedDoesNotClobberNewerDescriptor() { - let reducer = ChatSessionListReducer(workspaceID: "ws-1") + var reducer = ChatSessionListReducer(workspaceID: "ws-1") func desc(_ state: ChatAgentState, _ version: Int) -> ChatSessionDescriptor { ChatSessionDescriptor( id: "s1", agentKind: .codex, workspaceID: "ws-1", @@ -94,14 +95,85 @@ struct ChatSessionListReducerTests { @Test("a stateChanged for an unknown session never inserts") func stateChangedNoInsert() { - let reducer = ChatSessionListReducer(workspaceID: "ws-1") + var reducer = ChatSessionListReducer(workspaceID: "ws-1") let frame = ChatSessionEventFrame(sessionID: "ghost", event: .stateChanged(Self.working)) #expect(reducer.applying(frame, to: []).isEmpty) } + @Test("a sessionRemoved frame removes the matching row") + func sessionRemovedDeletesRow() { + var reducer = ChatSessionListReducer(workspaceID: "ws-1") + let seed = [descriptor("s1", version: 4), descriptor("s2")] + let frame = ChatSessionEventFrame(sessionID: "s1", event: .sessionRemoved(version: 5)) + #expect(reducer.applying(frame, to: seed).map(\.id) == ["s2"]) + } + + @Test("a stale descriptor after sessionRemoved cannot resurrect the row") + func sessionRemovedTombstonesStaleDescriptor() { + var reducer = ChatSessionListReducer(workspaceID: "ws-1") + let seed = [descriptor("s1", version: 4)] + let removed = ChatSessionEventFrame(sessionID: "s1", event: .sessionRemoved(version: 5)) + let stale = ChatSessionEventFrame(sessionID: "s1", event: .descriptorChanged(descriptor("s1", version: 4))) + let afterRemoval = reducer.applying(removed, to: seed) + #expect(afterRemoval.isEmpty) + #expect(reducer.applying(stale, to: afterRemoval).isEmpty) + } + + @Test("a newer descriptor after sessionRemoved can re-add the row") + func newerDescriptorClearsRemovalTombstone() { + var reducer = ChatSessionListReducer(workspaceID: "ws-1") + let seed = [descriptor("s1", version: 4)] + let removed = ChatSessionEventFrame(sessionID: "s1", event: .sessionRemoved(version: 5)) + let newer = ChatSessionEventFrame(sessionID: "s1", event: .descriptorChanged(descriptor("s1", version: 6))) + let afterRemoval = reducer.applying(removed, to: seed) + #expect(reducer.applying(newer, to: afterRemoval).map(\.id) == ["s1"]) + } + + @Test("an unversioned sessionRemoved deletes without permanently tombstoning") + func unversionedSessionRemovedDoesNotTombstoneFutureDescriptors() { + var reducer = ChatSessionListReducer(workspaceID: "ws-1") + let seed = [descriptor("s1", version: 4)] + let removed = ChatSessionEventFrame(sessionID: "s1", event: .sessionRemoved(version: Int.max)) + let replacement = ChatSessionEventFrame(sessionID: "s1", event: .descriptorChanged(descriptor("s1", version: 4))) + let afterRemoval = reducer.applying(removed, to: seed) + #expect(afterRemoval.isEmpty) + #expect(reducer.applying(replacement, to: afterRemoval).map(\.id) == ["s1"]) + } + + @Test("a versioned sessionRemoved for an unknown row tombstones stale descriptors") + func unknownVersionedSessionRemovedTombstonesFutureStaleDescriptors() { + var reducer = ChatSessionListReducer(workspaceID: "ws-1") + let removed = ChatSessionEventFrame(sessionID: "s1", event: .sessionRemoved(version: 5)) + let stale = ChatSessionEventFrame( + sessionID: "s1", + event: .descriptorChanged(descriptor("s1", version: 4)) + ) + let newer = ChatSessionEventFrame( + sessionID: "s1", + event: .descriptorChanged(descriptor("s1", version: 6)) + ) + let afterRemoval = reducer.applying(removed, to: []) + #expect(afterRemoval.isEmpty) + #expect(reducer.applying(stale, to: afterRemoval).isEmpty) + #expect(reducer.applying(newer, to: afterRemoval).map(\.id) == ["s1"]) + } + + @Test("an unversioned sessionRemoved for an unknown row does not tombstone future descriptors") + func unknownUnversionedSessionRemovedDoesNotTombstoneFutureDescriptors() { + var reducer = ChatSessionListReducer(workspaceID: "ws-1") + let removed = ChatSessionEventFrame(sessionID: "s1", event: .sessionRemoved(version: Int.max)) + let descriptor = ChatSessionEventFrame( + sessionID: "s1", + event: .descriptorChanged(descriptor("s1", version: 4)) + ) + let afterRemoval = reducer.applying(removed, to: []) + #expect(afterRemoval.isEmpty) + #expect(reducer.applying(descriptor, to: afterRemoval).map(\.id) == ["s1"]) + } + @Test("transcript-content frames leave the list untouched") func ignoresContentFrames() { - let reducer = ChatSessionListReducer(workspaceID: "ws-1") + var reducer = ChatSessionListReducer(workspaceID: "ws-1") let seed = [descriptor("s1")] let frames: [ChatSessionEvent] = [.appended([]), .updated([]), .reset, .unknown("x")] for event in frames { @@ -112,7 +184,7 @@ struct ChatSessionListReducerTests { @Test("a frame that races the seed converges (idempotent upsert)") func idempotentUpsert() { - let reducer = ChatSessionListReducer(workspaceID: "ws-1") + var reducer = ChatSessionListReducer(workspaceID: "ws-1") // The seed already contains s1; the racing descriptorChanged for the // same session must not duplicate it. let seed = [descriptor("s1", state: Self.working)] @@ -124,7 +196,7 @@ struct ChatSessionListReducerTests { @Test("a lower-version descriptorChanged is dropped; a higher one applies") func versionGatedUpsert() { - let reducer = ChatSessionListReducer(workspaceID: "ws-1") + var reducer = ChatSessionListReducer(workspaceID: "ws-1") func desc(_ state: ChatAgentState, _ version: Int) -> ChatSessionDescriptor { ChatSessionDescriptor( id: "s1", agentKind: .claude, workspaceID: "ws-1", diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/EventSource.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/EventSource.swift new file mode 100644 index 000000000000..c8d3a26fd142 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/EventSource.swift @@ -0,0 +1,21 @@ +@testable import CmuxAgentChat + +actor EventSource: ChatEventSource { + private var continuation: AsyncStream.Continuation? + + func history(sessionID: String, beforeSeq: Int?, limit: Int) async throws -> ChatHistoryPage { + ChatHistoryPage(messages: [], hasMore: false) + } + + func events(sessionID: String) async -> AsyncStream { + AsyncStream { self.continuation = $0 } + } + + func emit(_ event: ChatSessionEvent) { + continuation?.yield(event) + } + + func send(text: String, attachments: [ChatOutboundAttachment], sessionID: String) async throws {} + func interrupt(sessionID: String, hard: Bool) async throws {} + func answer(optionIndex: Int, sessionID: String) async throws {} +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift index 6169122d38fa..c21194778981 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift @@ -40,6 +40,21 @@ struct TerminalWireCodableTests { #expect(try decoder.decode(ChatSessionEvent.self, from: data) == event) } + @Test("ChatSessionEvent.sessionRemoved round-trips") + func sessionRemovedEventRoundTrip() throws { + let event = ChatSessionEvent.sessionRemoved(version: 9) + let data = try encoder.encode(event) + #expect(String(decoding: data, as: UTF8.self).contains("\"session_removed\"")) + #expect(String(decoding: data, as: UTF8.self).contains("\"version\":9")) + #expect(try decoder.decode(ChatSessionEvent.self, from: data) == event) + } + + @Test("ChatSessionEvent.sessionRemoved decodes missing version compatibly") + func sessionRemovedMissingVersionDecodesAsUnversioned() throws { + let data = #"{"event":"session_removed"}"#.data(using: .utf8)! + #expect(try decoder.decode(ChatSessionEvent.self, from: data) == .sessionRemoved(version: Int.max)) + } + @Test("ChatHistoryPage carries terminal blocks and stays backward-compatible") func historyPageTerminal() throws { let page = ChatHistoryPage( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatSessionRefreshOutcome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatSessionRefreshOutcome.swift index 89b696f81184..8146eeedddf8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatSessionRefreshOutcome.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatSessionRefreshOutcome.swift @@ -1,4 +1,5 @@ import CmuxAgentChat +import Foundation /// Result of refreshing a workspace's chat-session list. enum WorkspaceChatSessionRefreshOutcome: Equatable { @@ -27,3 +28,56 @@ enum WorkspaceChatSessionRefreshOutcome: Equatable { } } } + +extension ChatSessionEventFrame { + func shouldPullAuthoritativeSnapshotForIgnoredWorkspaceFrame( + workspaceID: String, + selectedTerminalID: String?, + cachedChatToggleTerminalID: String? + ) -> Bool { + guard case .descriptorChanged(let descriptor) = event, + descriptor.workspaceID != workspaceID, + let terminalID = descriptor.terminalID else { + return false + } + return terminalID == selectedTerminalID || terminalID == cachedChatToggleTerminalID + } +} + +extension Collection where Element == ChatSessionDescriptor { + func replacementSessionIDForPinnedChat( + pinnedID: String, + cachedTerminalID: String? + ) -> String? { + let pinned = first { $0.id == pinnedID } + guard pinned == nil || pinned?.state == .ended else { return nil } + let terminalID = pinned?.terminalID ?? cachedTerminalID + guard let terminalID else { return nil } + return filter { $0.terminalID == terminalID && $0.id != pinnedID && $0.state != .ended } + .max { ($0.lastActivityAt ?? .distantPast) < ($1.lastActivityAt ?? .distantPast) }? + .id + } +} + +extension Array where Element == ChatSessionDescriptor { + func preservingPinnedPendingAliasRemoval( + previous: [ChatSessionDescriptor], + frame: ChatSessionEventFrame, + pinnedID: String?, + cachedTerminalID: String? + ) -> [ChatSessionDescriptor] { + guard case .sessionRemoved = frame.event, + let pinnedID, + frame.sessionID == pinnedID, + pinnedID.hasPrefix("pending-claude-"), + !contains(where: { $0.id == pinnedID }), + replacementSessionIDForPinnedChat( + pinnedID: pinnedID, + cachedTerminalID: cachedTerminalID + ) == nil, + let pinned = previous.first(where: { $0.id == pinnedID }) else { + return self + } + return self + [pinned.withState(.ended)] + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift index 4b1196644f23..b32cc2b8c7b8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift @@ -152,7 +152,7 @@ extension WorkspaceDetailView { applyChatModeFallback(canInvalidateSelection: false) return } - let reducer = ChatSessionListReducer(workspaceID: workspaceID) + var reducer = ChatSessionListReducer(workspaceID: workspaceID) let stream = await source.sessionEvents() let seedOutcome: WorkspaceChatSessionRefreshOutcome do { @@ -181,8 +181,22 @@ extension WorkspaceDetailView { sourceIdentity == store.agentChatEventSourceIdentity else { break } let current = visibleChatSessions - let next = reducer.applying(frame, to: current) - guard next != current else { continue } + let reduced = reducer.applying(frame, to: current) + let next = reduced.preservingPinnedPendingAliasRemoval( + previous: current, + frame: frame, + pinnedID: pinnedChatSessionID, + cachedTerminalID: cachedChatToggleTerminalID + ) + guard next != current else { + _ = await refreshAfterIgnoredChatSessionFrameIfNeeded( + frame, + source: source, + workspaceID: workspaceID, + sourceIdentity: sourceIdentity + ) + continue + } withAnimation(.snappy(duration: 0.25)) { chatSessionsWorkspaceID = workspaceID chatSessions = next @@ -192,6 +206,87 @@ extension WorkspaceDetailView { } } + /// If a live descriptor push names the selected terminal but carries a stale + /// or missing workspace id, a scoped reducer correctly ignores it. Pull the + /// authoritative workspace snapshot once so the toolbar toggle appears + /// without requiring the user to leave and re-enter the workspace. + private func refreshAfterIgnoredChatSessionFrameIfNeeded( + _ frame: ChatSessionEventFrame, + source: MobileChatEventSource, + workspaceID: String, + sourceIdentity: String + ) async -> Bool { + guard frame.shouldPullAuthoritativeSnapshotForIgnoredWorkspaceFrame( + workspaceID: workspaceID, + selectedTerminalID: selectedTerminalID, + cachedChatToggleTerminalID: cachedChatToggleTerminalID + ) + else { return false } + guard !Task.isCancelled else { return false } + let sessions: [ChatSessionDescriptor] + guard let refreshed = await coalescedIgnoredChatSessionSnapshot( + source: source, + workspaceID: workspaceID, + sourceIdentity: sourceIdentity + ) else { + return false + } + sessions = refreshed + guard !Task.isCancelled, + workspaceID == workspace.id.rawValue, + sourceIdentity == store.agentChatEventSourceIdentity + else { return false } + let next = WorkspaceChatSessionRefreshOutcome.authoritative(sessions) + .applying(to: visibleChatSessions) + guard next != visibleChatSessions else { return true } + withAnimation(.snappy(duration: 0.25)) { + chatSessionsWorkspaceID = workspaceID + chatSessions = next + } + store.rememberChatSessions(next, workspaceID: workspaceID) + reconcileChatSessionSnapshot(seedOutcomeCanInvalidateSelection: true) + return true + } + + private func coalescedIgnoredChatSessionSnapshot( + source: MobileChatEventSource, + workspaceID: String, + sourceIdentity: String + ) async -> [ChatSessionDescriptor]? { + let key = "\(workspaceID)#\(sourceIdentity)" + if let task = ignoredChatSessionRefreshTask, + ignoredChatSessionRefreshKey == key { + return await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } + let taskID = UUID() + let task = Task { () -> [ChatSessionDescriptor]? in + do { + return try await source.sessions(workspaceID: workspaceID) + } catch { + return nil + } + } + ignoredChatSessionRefreshKey = key + ignoredChatSessionRefreshID = taskID + ignoredChatSessionRefreshTask = task + let result = await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + if ignoredChatSessionRefreshKey == key, + ignoredChatSessionRefreshID == taskID { + ignoredChatSessionRefreshKey = nil + ignoredChatSessionRefreshID = nil + ignoredChatSessionRefreshTask = nil + } + return result + } + /// Runs the selected terminal's chat store while terminal mode is visible. /// Opening chat reuses the same store, so there is only one subscription and /// the transcript/history loaded in the background remains available. @@ -289,15 +384,12 @@ extension WorkspaceDetailView { /// so the GUI becomes editable again. private func repinToReopenedSession() { guard isChatMode, - let pinnedID = pinnedChatSessionID, - let pinned = visibleChatSessions.first(where: { $0.id == pinnedID }), - pinned.state == .ended, - let terminalID = pinned.terminalID else { return } - let live = visibleChatSessions - .filter { $0.terminalID == terminalID && $0.id != pinnedID && $0.state != .ended } - .max { ($0.lastActivityAt ?? .distantPast) < ($1.lastActivityAt ?? .distantPast) } - if let live { - pinnedChatSessionID = live.id + let pinnedID = pinnedChatSessionID else { return } + if let replacementID = visibleChatSessions.replacementSessionIDForPinnedChat( + pinnedID: pinnedID, + cachedTerminalID: cachedChatToggleTerminalID + ) { + pinnedChatSessionID = replacementID } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index d546c9ccfe3c..9c6cffb705cd 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -61,6 +61,9 @@ struct WorkspaceDetailView: View { @State var chatSessionsWorkspaceID: String? /// Last terminal id whose cached snapshot said it had a chat session. @State var cachedChatToggleTerminalID: String? + @State var ignoredChatSessionRefreshKey: String? + @State var ignoredChatSessionRefreshID: UUID? + @State var ignoredChatSessionRefreshTask: Task<[ChatSessionDescriptor]?, Never>? /// Per-session chat stores kept warm while the workspace detail is visible. @State var chatConversationStores: [String: ChatConversationStore] = [:] /// Per-session composer drafts, surviving toggles back to the terminal. diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceChatSessionRefreshOutcomeTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceChatSessionRefreshOutcomeTests.swift index 230ed6676fe7..baf29274b360 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceChatSessionRefreshOutcomeTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceChatSessionRefreshOutcomeTests.swift @@ -3,13 +3,17 @@ import Testing @testable import CmuxMobileShellUI @Suite struct WorkspaceChatSessionRefreshOutcomeTests { - private func descriptor(_ id: String) -> ChatSessionDescriptor { + private func descriptor( + _ id: String, + terminalID: String? = "terminal", + state: ChatAgentState = .idle + ) -> ChatSessionDescriptor { ChatSessionDescriptor( id: id, agentKind: .claude, workspaceID: "workspace", - terminalID: "terminal", - state: .idle + terminalID: terminalID, + state: state ) } @@ -32,4 +36,128 @@ import Testing #expect(result == fresh) #expect(outcome.canInvalidateSelection) } + + @Test func staleWorkspaceDescriptorForSelectedTerminalRequestsAuthoritativePull() { + let frame = ChatSessionEventFrame( + sessionID: "session-1", + event: .descriptorChanged(ChatSessionDescriptor( + id: "session-1", + agentKind: .claude, + workspaceID: "stale-workspace", + terminalID: "terminal", + state: .idle + )) + ) + + #expect(frame.shouldPullAuthoritativeSnapshotForIgnoredWorkspaceFrame( + workspaceID: "workspace", + selectedTerminalID: "terminal", + cachedChatToggleTerminalID: nil + )) + } + + @Test func ignoredDescriptorForOtherTerminalDoesNotPull() { + let frame = ChatSessionEventFrame( + sessionID: "session-1", + event: .descriptorChanged(ChatSessionDescriptor( + id: "session-1", + agentKind: .claude, + workspaceID: "other-workspace", + terminalID: "other-terminal", + state: .idle + )) + ) + + #expect(!frame.shouldPullAuthoritativeSnapshotForIgnoredWorkspaceFrame( + workspaceID: "workspace", + selectedTerminalID: "terminal", + cachedChatToggleTerminalID: nil + )) + } + + @Test func removedPinnedAliasMigratesToLiveSessionOnCachedTerminal() { + let sessions = [ + descriptor("real-session", terminalID: "terminal", state: .idle), + descriptor("other-session", terminalID: "other-terminal", state: .idle), + ] + + #expect(sessions.replacementSessionIDForPinnedChat( + pinnedID: "pending-claude-terminal", + cachedTerminalID: "terminal" + ) == "real-session") + } + + @Test func livePinnedSessionDoesNotMigrate() { + let sessions = [ + descriptor("pending-claude-terminal", terminalID: "terminal", state: .idle), + descriptor("real-session", terminalID: "terminal", state: .idle), + ] + + #expect(sessions.replacementSessionIDForPinnedChat( + pinnedID: "pending-claude-terminal", + cachedTerminalID: "terminal" + ) == nil) + } + + @Test func removedPinnedPendingAliasIsPreservedUntilReplacementDescriptorArrives() { + let previous = [ + descriptor("pending-claude-terminal", terminalID: "terminal", state: .idle), + ] + let frame = ChatSessionEventFrame( + sessionID: "pending-claude-terminal", + event: .sessionRemoved(version: 3) + ) + + let result = [ChatSessionDescriptor]().preservingPinnedPendingAliasRemoval( + previous: previous, + frame: frame, + pinnedID: "pending-claude-terminal", + cachedTerminalID: "terminal" + ) + + #expect(result.count == 1) + #expect(result.first?.id == "pending-claude-terminal") + #expect(result.first?.state == .ended) + } + + @Test func removedPinnedPendingAliasDoesNotPreserveWhenReplacementAlreadyExists() { + let previous = [ + descriptor("pending-claude-terminal", terminalID: "terminal", state: .idle), + ] + let next = [ + descriptor("real-session", terminalID: "terminal", state: .idle), + ] + let frame = ChatSessionEventFrame( + sessionID: "pending-claude-terminal", + event: .sessionRemoved(version: 3) + ) + + let result = next.preservingPinnedPendingAliasRemoval( + previous: previous, + frame: frame, + pinnedID: "pending-claude-terminal", + cachedTerminalID: "terminal" + ) + + #expect(result == next) + } + + @Test func removedNonPendingPinnedSessionDoesNotPreserve() { + let previous = [ + descriptor("real-session", terminalID: "terminal", state: .idle), + ] + let frame = ChatSessionEventFrame( + sessionID: "real-session", + event: .sessionRemoved(version: 3) + ) + + let result = [ChatSessionDescriptor]().preservingPinnedPendingAliasRemoval( + previous: previous, + frame: frame, + pinnedID: "real-session", + cachedTerminalID: "terminal" + ) + + #expect(result.isEmpty) + } } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/NotificationObserverToken.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/NotificationObserverToken.swift index 924606fba7bd..0aacf76ff968 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/NotificationObserverToken.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/NotificationObserverToken.swift @@ -9,12 +9,14 @@ import Foundation /// lifecycle and must call `remove()` when their stream terminates. final class NotificationObserverToken: @unchecked Sendable { private let token: NSObjectProtocol + private let notificationCenter: NotificationCenter - init(_ token: NSObjectProtocol) { + init(_ token: NSObjectProtocol, notificationCenter: NotificationCenter = .default) { self.token = token + self.notificationCenter = notificationCenter } func remove() { - NotificationCenter.default.removeObserver(token) + notificationCenter.removeObserver(token) } } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift index 16466646a17f..7a6e305318c4 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift @@ -5,9 +5,11 @@ import Foundation // instance across actor boundaries. final class UserDefaultsSettingsStorage: @unchecked Sendable { private let defaults: UserDefaults + private let notificationCenter: NotificationCenter - init(defaults: UserDefaults) { + init(defaults: UserDefaults, notificationCenter: NotificationCenter = .default) { self.defaults = defaults + self.notificationCenter = notificationCenter } func value(for key: DefaultsKey) -> Value { @@ -30,7 +32,7 @@ final class UserDefaultsSettingsStorage: @unchecked Sendable { ) -> NotificationObserverToken { let defaultsID = ObjectIdentifier(defaults) return NotificationObserverToken( - NotificationCenter.default.addObserver( + notificationCenter.addObserver( forName: UserDefaults.didChangeNotification, object: nil, queue: nil @@ -38,7 +40,8 @@ final class UserDefaultsSettingsStorage: @unchecked Sendable { let objectID = notification.object.map { ObjectIdentifier($0 as AnyObject) } let isBackingDefaultsNotification = objectID == defaultsID handler(isBackingDefaultsNotification, objectID == nil || isBackingDefaultsNotification) - } + }, + notificationCenter: notificationCenter ) } } diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift index 1013f9786a05..4d7dfc6e8ec7 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift @@ -8,7 +8,11 @@ struct UserDefaultsSettingsStoreObservationTests { @Test func storageChangeObserverClassifiesDefaultsNotifications() async { let observedDefaults = UserDefaults(suiteName: "cmux.tests.\(UUID().uuidString)")! let otherDefaults = UserDefaults(suiteName: "cmux.tests.\(UUID().uuidString)")! - let storage = UserDefaultsSettingsStorage(defaults: observedDefaults) + let notificationCenter = NotificationCenter() + let storage = UserDefaultsSettingsStorage( + defaults: observedDefaults, + notificationCenter: notificationCenter + ) let (stream, continuation) = AsyncStream<(Bool, Bool)>.makeStream(bufferingPolicy: .unbounded) let token = storage.addDidChangeObserver { isBackingDefaultsNotification, canCarryActiveMutationSource in continuation.yield((isBackingDefaultsNotification, canCarryActiveMutationSource)) @@ -18,9 +22,9 @@ struct UserDefaultsSettingsStoreObservationTests { continuation.finish() } - NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: otherDefaults) - NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: nil) - NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: observedDefaults) + notificationCenter.post(name: UserDefaults.didChangeNotification, object: otherDefaults) + notificationCenter.post(name: UserDefaults.didChangeNotification, object: nil) + notificationCenter.post(name: UserDefaults.didChangeNotification, object: observedDefaults) var iterator = stream.makeAsyncIterator() let firstEvent = await iterator.next() diff --git a/Sources/Mobile/AgentChat/AgentChatEndedTranscriptListabilityCache.swift b/Sources/Mobile/AgentChat/AgentChatEndedTranscriptListabilityCache.swift new file mode 100644 index 000000000000..82082c27e12d --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatEndedTranscriptListabilityCache.swift @@ -0,0 +1,72 @@ +import Foundation + +struct AgentChatEndedTranscriptListabilityCache { + private static let missingTranscriptRetryWindow: TimeInterval = 5 + private var entryBySessionID: [String: (isReadable: Bool, firstMissingAt: Date?)] = [:] + + mutating func shouldList( + _ record: AgentChatSessionRecord, + resolver: AgentChatTranscriptResolver, + now: Date = Date() + ) -> Bool { + guard record.state == .ended else { + entryBySessionID.removeValue(forKey: record.sessionID) + return false + } + guard let entry = entryBySessionID[record.sessionID] else { + return refresh(record, resolver: resolver, preservingFirstMissingAt: nil, now: now) + } + if entry.isReadable { + return true + } + if let firstMissingAt = entry.firstMissingAt, + now.timeIntervalSince(firstMissingAt) < Self.missingTranscriptRetryWindow { + return false + } + return refresh(record, resolver: resolver, preservingFirstMissingAt: nil, now: now) + } + + @discardableResult + mutating func update( + _ record: AgentChatSessionRecord, + previous: AgentChatSessionRecord?, + resolver: AgentChatTranscriptResolver, + now: Date = Date() + ) -> Bool { + guard record.state == .ended else { + entryBySessionID.removeValue(forKey: record.sessionID) + return false + } + if let previous, + previous.state == .ended, + previous.transcriptPath == record.transcriptPath, + previous.workingDirectory == record.workingDirectory, + previous.hookStoreSessionID == record.hookStoreSessionID { + return shouldList(record, resolver: resolver, now: now) + } + return refresh( + record, + resolver: resolver, + preservingFirstMissingAt: nil, + now: now + ) + } + + private mutating func refresh( + _ record: AgentChatSessionRecord, + resolver: AgentChatTranscriptResolver, + preservingFirstMissingAt firstMissingAt: Date?, + now: Date + ) -> Bool { + let isReadable = resolver.boundedTranscriptPath(for: record) != nil + entryBySessionID[record.sessionID] = ( + isReadable: isReadable, + firstMissingAt: isReadable ? nil : (firstMissingAt ?? now) + ) + return isReadable + } + + mutating func remove(sessionID: String) { + entryBySessionID.removeValue(forKey: sessionID) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatObservationHandle.swift b/Sources/Mobile/AgentChat/AgentChatObservationHandle.swift new file mode 100644 index 000000000000..3bbe8227f8c5 --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatObservationHandle.swift @@ -0,0 +1,6 @@ +import Foundation + +struct AgentChatObservationHandle: Sendable { + let id: UUID + let task: Task +} diff --git a/Sources/Mobile/AgentChat/AgentChatObservationInFlight.swift b/Sources/Mobile/AgentChat/AgentChatObservationInFlight.swift new file mode 100644 index 000000000000..6ff39c42451f --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatObservationInFlight.swift @@ -0,0 +1,12 @@ +import Foundation + +struct AgentChatObservationInFlight { + let id: UUID + let scope: AgentChatObservationScope + let task: Task + var waiters: [UUID: (continuation: CheckedContinuation, timer: DispatchSourceTimer?)] = [:] + + var handle: AgentChatObservationHandle { + AgentChatObservationHandle(id: id, task: task) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatObservationScope.swift b/Sources/Mobile/AgentChat/AgentChatObservationScope.swift new file mode 100644 index 000000000000..a5052e8bf00e --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatObservationScope.swift @@ -0,0 +1,34 @@ +import Foundation + +enum AgentChatObservationScope: Equatable, Sendable { + case all + case surfaces(Set) + + init(surfaceIDs: Set?) { + if let surfaceIDs { + self = .surfaces(surfaceIDs) + } else { + self = .all + } + } + + var surfaceIDs: Set? { + switch self { + case .all: + return nil + case .surfaces(let ids): + return ids + } + } + + func covers(_ requested: AgentChatObservationScope) -> Bool { + switch (self, requested) { + case (.all, _): + return true + case (.surfaces, .all): + return false + case (.surfaces(let current), .surfaces(let requestedIDs)): + return current.isSuperset(of: requestedIDs) + } + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRecord.swift b/Sources/Mobile/AgentChat/AgentChatSessionRecord.swift index 9e3768bae042..439138b5b172 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRecord.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRecord.swift @@ -26,6 +26,10 @@ struct AgentChatSessionRecord: Sendable { /// Live activity state derived from hook events. var state: ChatAgentState + /// When the record entered `.ended`. Best-effort process observations sampled + /// before this point must not revive it after a hook or exit watcher ended it. + var endedAt: Date? + /// Timestamp of the most recent hook or transcript activity. var lastActivityAt: Date @@ -35,11 +39,20 @@ struct AgentChatSessionRecord: Sendable { /// The agent process id, for liveness sweeps. var pid: Int? + /// Real hook-store key, when this record is surfaced under a pending alias. + var hookStoreSessionID: String? + /// Monotonic revision stamped by the registry on every change, so clients /// can reconcile best-effort pushes against authoritative pulls. Owned by /// the registry; mutators do not set it directly. var version: Int = 0 + var hookStoreLookupSessionID: String { hookStoreSessionID ?? sessionID } + + mutating func rememberHookStoreSessionID(_ id: String) { + if id != sessionID { hookStoreSessionID = id } + } + /// Adopts terminal/transcript bindings from a hook-store entry. The /// store is rewritten by every hook event, so its non-nil fields are /// fresher than the record's (panel UUIDs change across app @@ -56,6 +69,7 @@ struct AgentChatSessionRecord: Sendable { from entry: AgentChatHookSessionStore.Entry, includingPID: Bool = true ) { + rememberHookStoreSessionID(entry.sessionID) surfaceID = entry.surfaceID ?? surfaceID workspaceID = entry.workspaceID ?? workspaceID transcriptPath = entry.transcriptPath ?? transcriptPath @@ -65,6 +79,19 @@ struct AgentChatSessionRecord: Sendable { } } + /// Fills gaps from the hook store without replacing live cmux bindings. + mutating func adoptMissingBindings( + from entry: AgentChatHookSessionStore.Entry, + includingPID: Bool = true + ) { + rememberHookStoreSessionID(entry.sessionID) + if surfaceID == nil { surfaceID = entry.surfaceID } + if workspaceID == nil { workspaceID = entry.workspaceID } + if transcriptPath == nil { transcriptPath = entry.transcriptPath } + if workingDirectory == nil { workingDirectory = entry.workingDirectory } + if includingPID, pid == nil { pid = entry.pid } + } + /// The wire descriptor for this record. var descriptor: ChatSessionDescriptor { ChatSessionDescriptor( diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry+Lifecycle.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+Lifecycle.swift new file mode 100644 index 000000000000..4935928c728b --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+Lifecycle.swift @@ -0,0 +1,107 @@ +import CMUXAgentLaunch +import CmuxAgentChat +import Foundation + +/// A coding-agent session discovered by observing the process table, with no +/// dependency on hooks firing. Identity (and, for codex, the transcript path) +/// comes from the agent's own argv, environment, or open transcript file, so a +/// session launched through any indirection (a subrouter, a wrapper) is still +/// found. +nonisolated struct ObservedAgentSession: Sendable { + let sessionID: String + let agentKind: ChatAgentKind + let surfaceID: String + let workspaceID: String? + let pid: Int + let workingDirectory: String? + let transcriptPath: String? + let sampledAt: Date + + init( + sessionID: String, + agentKind: ChatAgentKind, + surfaceID: String, + workspaceID: String?, + pid: Int, + workingDirectory: String?, + transcriptPath: String?, + sampledAt: Date = Date() + ) { + self.sessionID = sessionID + self.agentKind = agentKind + self.surfaceID = surfaceID + self.workspaceID = workspaceID + self.pid = pid + self.workingDirectory = workingDirectory + self.transcriptPath = transcriptPath + self.sampledAt = sampledAt + } +} + +extension AgentChatSessionRegistry { + func stampLifecycleTransition( + previous: AgentChatSessionRecord?, + current: inout AgentChatSessionRecord, + at transitionAt: Date + ) { + let wasEnded = previous.map { Self.stateIsEnded($0.state) } ?? false + let isEnded = Self.stateIsEnded(current.state) + if isEnded { + if wasEnded { + current.endedAt = current.endedAt ?? previous?.endedAt ?? transitionAt + } else { + current.endedAt = transitionAt + } + } else { + current.endedAt = nil + } + } + + /// Strips an agent-name prefix from prefixed workstream ids + /// (`claude-`); raw hook ids pass through. + static func normalizedSessionID(_ id: String, source: String) -> String { + let prefix = "\(source)-" + if id.hasPrefix(prefix) { + return String(id.dropFirst(prefix.count)) + } + return id + } + + nonisolated static func nextState( + previous: ChatAgentState, + event: WorkstreamEvent + ) -> ChatAgentState { + if stateIsEnded(previous), event.hookEventName != .sessionStart { + return .ended + } + switch event.hookEventName { + case .sessionStart: + return .idle + case .userPromptSubmit, .preToolUse, .postToolUse, .todoWrite: + if case .working = previous { return previous } + return .working(since: event.receivedAt) + case .preCompact, .postCompact: + // Compaction is lifecycle telemetry. It can occur while a session + // is idle, so it must not create a synthetic working state. + return previous + case .permissionRequest, .askUserQuestion, .exitPlanMode, .notification: + if case .needsInput = previous { return previous } + return .needsInput(since: event.receivedAt) + case .stop: + return .idle + case .subagentStart, .subagentStop: + // Task subagent lifecycle says nothing about the parent + // session's activity; keep the current state. + return previous + case .sessionEnd: + return .ended + } + } + + nonisolated static func stateIsEnded(_ state: ChatAgentState) -> Bool { + if case .ended = state { + return true + } + return false + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry+LiveAgentPID.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+LiveAgentPID.swift new file mode 100644 index 000000000000..4a5cc5138915 --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+LiveAgentPID.swift @@ -0,0 +1,216 @@ +import CMUXAgentLaunch +import CmuxAgentChat +import Foundation + +extension AgentChatSessionRegistry { + /// Observe-floor liveness: the pid of a live foreground agent process + /// matching `kind` under `surfaceID`'s process tree, or nil if none. + /// + /// A launcher or intermediate process (a subrouter like `sr`, a `node` + /// shim) is NOT the agent; the real agent binary (e.g. `codex`, `claude`) + /// appears deeper in the tree. So liveness must be judged from the whole + /// foreground process tree under the surface, never from a single recorded + /// pid that may be a launcher or from background descendants that would not + /// receive terminal input. Nonisolated and snapshot-based so it runs off the + /// main actor; callers hop back to the main actor to apply the result. The + /// classifier is shared with observe-floor detection, so argv-hosted agents + /// (`node .../claude-code`, `npx .../codex`) rebind the same way they are + /// first discovered. + nonisolated static func liveAgentPID( + surfaceID: String, + kind: ChatAgentKind, + matchingSessionIDs expectedSessionIDs: Set, + allowUnidentifiedFallback: Bool = false + ) -> Int? { + guard !expectedSessionIDs.isEmpty else { return nil } + let snapshot = CmuxTopProcessSnapshot.capture( + includeProcessDetails: true, + includeCMUXScope: true + ) + return liveAgentPID( + in: snapshot, + surfaceID: surfaceID, + kind: kind, + matchingSessionIDs: expectedSessionIDs, + allowUnidentifiedFallback: allowUnidentifiedFallback, + processArgumentsAndEnvironment: CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for:) + ) + } + + nonisolated static func liveAgentPID( + in snapshot: CmuxTopProcessSnapshot, + surfaceID: String, + kind: ChatAgentKind, + processArgumentsAndEnvironment: (Int) -> CmuxTopProcessArguments? + ) -> Int? { + liveAgentPID( + in: snapshot, + surfaceID: surfaceID, + kind: kind, + matchingSessionIDs: nil, + allowUnidentifiedFallback: false, + processArgumentsAndEnvironment: processArgumentsAndEnvironment + ) + } + + nonisolated static func liveAgentPID( + in snapshot: CmuxTopProcessSnapshot, + surfaceID: String, + kind: ChatAgentKind, + matchingSessionIDs expectedSessionIDs: Set?, + allowUnidentifiedFallback: Bool = false, + processArgumentsAndEnvironment: (Int) -> CmuxTopProcessArguments? + ) -> Int? { + guard let surfaceUUID = UUID(uuidString: surfaceID) else { return nil } + let rootPIDs = cmuxSurfaceRootPIDs(surfaceID: surfaceUUID, snapshot: snapshot) + guard !rootPIDs.isEmpty else { return nil } + let wantedID = kind.sourceName + var matchedPID: (pid: Int, depth: Int)? + var unidentifiedFallbackPID: (pid: Int, depth: Int)? + var sawMismatchedSessionIdentity = false + let expandedPIDs = snapshot.expandedPIDs(rootPIDs: rootPIDs) + for pid in expandedPIDs.sorted() { + let depth = processTreeDepth(pid: pid, rootPIDs: rootPIDs, snapshot: snapshot) + var details: CmuxTopProcessArguments? + func loadDetails() -> CmuxTopProcessArguments? { + if details == nil { + details = processArgumentsAndEnvironment(pid) + } + return details + } + guard let info = snapshot.process(pid: pid), + info.isTerminalForegroundProcessGroup, + let def = codingAgentDefinition( + for: info, + allowLaunchKindEnvironment: allowsLaunchKindEnvironment( + for: info, + rootPIDs: rootPIDs, + arguments: rootPIDs.contains(pid) ? nil : loadDetails()?.arguments + ), + processArgumentsAndEnvironment: { _ in loadDetails() } + ), + def.id == wantedID else { continue } + if let expectedSessionIDs { + guard let candidateSessionID = observedSessionID( + agentID: def.id, + pid: pid, + details: loadDetails() + ) else { + if allowUnidentifiedFallback { + unidentifiedFallbackPID = preferredLiveAgentPID( + current: unidentifiedFallbackPID, + candidate: (pid, depth) + ) + } + continue + } + if expectedSessionIDs.contains(candidateSessionID) { + matchedPID = preferredLiveAgentPID( + current: matchedPID, + candidate: (pid, depth) + ) + } else { + sawMismatchedSessionIdentity = true + } + continue + } + matchedPID = preferredLiveAgentPID( + current: matchedPID, + candidate: (pid, depth) + ) + } + if let matchedPID { + return matchedPID.pid + } + if !sawMismatchedSessionIdentity { + return unidentifiedFallbackPID?.pid + } + return nil + } + + nonisolated static func preferredLiveAgentPID( + current: (pid: Int, depth: Int)?, + candidate: (pid: Int, depth: Int) + ) -> (pid: Int, depth: Int) { + guard let current else { return candidate } + if candidate.depth > current.depth { + return candidate + } + if candidate.depth == current.depth, + candidate.pid > current.pid { + return candidate + } + return current + } + + nonisolated static func cmuxSurfaceRootPIDs( + surfaceID: UUID, + snapshot: CmuxTopProcessSnapshot + ) -> Set { + let pids = snapshot.pids(forCMUXSurfaceID: surfaceID) + return Set(pids.filter { pid in + guard let parentPID = snapshot.process(pid: pid)?.parentPID else { + return true + } + return !pids.contains(parentPID) + }) + } + + nonisolated static func processTreeDepth( + pid: Int, + rootPIDs: Set, + snapshot: CmuxTopProcessSnapshot + ) -> Int { + guard pid > 0 else { return 0 } + var currentPID = pid + var depth = 0 + var visited: Set = [] + while !rootPIDs.contains(currentPID) { + guard visited.insert(currentPID).inserted, + let parentPID = snapshot.process(pid: currentPID)?.parentPID, + parentPID > 0 else { + break + } + currentPID = parentPID + depth += 1 + } + return depth + } + + nonisolated static func pendingClaudeSessionID(surfaceID: String) -> String { + "pending-claude-\(surfaceID)" + } + + nonisolated static func pendingClaudeSessionID(surfaceID: String, pid: Int) -> String { + "pending-claude-\(surfaceID)-pid-\(pid)" + } + + nonisolated static func isPendingClaudeSessionID(_ sessionID: String) -> Bool { + sessionID.hasPrefix("pending-claude-") + } + + private nonisolated static func observedSessionID( + agentID: String, + pid: Int, + details: CmuxTopProcessArguments? + ) -> String? { + if agentID == "codex", + let rollout = openCodexRolloutPath(pid: pid) { + return firstUUIDLike(in: (rollout as NSString).lastPathComponent) + } + let isClaudeForkLaunch = agentID == "claude" + && (details?.arguments).map(Self.containsClaudeForkSessionOption(_:)) == true + if isClaudeForkLaunch { + return nil + } + if agentID == "claude", + let envSessionID = details?.environment["CLAUDE_CODE_SESSION_ID"], + let id = firstUUIDLike(in: envSessionID) { + return id + } + if let argv = details?.arguments { + return sessionIDFromArguments(argv) + } + return nil + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ObserveScan.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ObserveScan.swift new file mode 100644 index 000000000000..2c5a4527386e --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ObserveScan.swift @@ -0,0 +1,499 @@ +import CMUXAgentLaunch +import CmuxAgentChat +import Foundation + +extension AgentChatSessionRegistry { + func reviveEndedObservedSessionIfNeeded( + current: AgentChatSessionRecord, + observed session: ObservedAgentSession, + now: Date + ) -> Bool { + guard observationCanReviveEndedSession(current: current, observed: session) else { + return false + } + if reviveEndedPendingClaudeSessionIfNeeded(current: current, observed: session, now: now) { + return true + } + update(sessionID: current.sessionID) { record in + record.workspaceID = session.workspaceID ?? record.workspaceID + record.surfaceID = session.surfaceID + record.workingDirectory = session.workingDirectory ?? record.workingDirectory + record.transcriptPath = session.transcriptPath ?? record.transcriptPath + record.pid = session.pid + record.state = .idle + record.lastActivityAt = now + } + return true + } + + func reviveEndedPendingClaudeSessionIfNeeded( + current: AgentChatSessionRecord, + observed session: ObservedAgentSession, + now: Date + ) -> Bool { + guard current.state == .ended, + session.agentKind == .claude, + Self.isPendingClaudeSessionID(current.sessionID), + !endedPendingClaudeSessionHasHistoryIdentity(current) else { + return false + } + update(sessionID: current.sessionID) { record in + record.workspaceID = session.workspaceID ?? record.workspaceID + record.surfaceID = session.surfaceID + record.workingDirectory = session.workingDirectory ?? record.workingDirectory + record.transcriptPath = session.transcriptPath ?? record.transcriptPath + record.pid = session.pid + record.state = .idle + record.lastActivityAt = now + } + return true + } + + func observedClaudeSessionID( + canonicalSessionID: String, + observed session: ObservedAgentSession + ) -> String { + guard let current = record(sessionID: canonicalSessionID), + current.state == .ended, + endedPendingClaudeSessionHasHistoryIdentity(current), + observationCanReviveEndedSession(current: current, observed: session), + session.agentKind == .claude, + Self.isPendingClaudeSessionID(canonicalSessionID) else { + return canonicalSessionID + } + return Self.pendingClaudeSessionID(surfaceID: session.surfaceID, pid: session.pid) + } + + func observeAgentProcesses() async { + if let observation = observeAgentProcessesTask(scope: .all, force: true) { + await observation.task.value + } + } + + func observeAgentProcessesForListing(surfaceIDs: Set?, waitUpTo timeout: Duration) async -> Bool { + if let surfaceIDs, surfaceIDs.isEmpty { + return true + } + let scope = AgentChatObservationScope(surfaceIDs: surfaceIDs) + let force = surfaceIDs != nil + guard let observation = observeAgentProcessesTask(scope: scope, force: force) else { + return true + } + return await waitForObservation(observation, upTo: timeout) + } + + func waitForObservation(_ observation: AgentChatObservationHandle, upTo timeout: Duration) async -> Bool { + guard observeInFlight?.id == observation.id else { + return true + } + let waiterID = UUID() + return await withCheckedContinuation { continuation in + guard var inFlight = observeInFlight, inFlight.id == observation.id else { + continuation.resume(returning: true) + return + } + let timeoutSeconds = Self.timeInterval(for: timeout) + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.setEventHandler { [weak self, weak timer] in + Task { @MainActor [weak self, weak timer] in + guard let self, + var current = self.observeInFlight, + current.id == observation.id, + let waiter = current.waiters.removeValue(forKey: waiterID) else { return } + timer?.cancel() + waiter.timer?.cancel() + self.observeInFlight = current + waiter.continuation.resume(returning: false) + } + } + inFlight.waiters[waiterID] = (continuation: continuation, timer: timer) + observeInFlight = inFlight + timer.schedule(deadline: .now() + timeoutSeconds) + timer.resume() + } + } + + private func finishAgentProcessObservation(id: UUID) { + guard let inFlight = observeInFlight, inFlight.id == id else { + return + } + observeInFlight = nil + resumeAgentProcessObservationWaiters(inFlight, returning: true) + } + + func replaceAgentProcessObservation(with inFlight: AgentChatObservationInFlight) { + if let current = observeInFlight { + current.task.cancel() + observeInFlight = nil + resumeAgentProcessObservationWaiters(current, returning: false) + } + observeInFlight = inFlight + } + + private func resumeAgentProcessObservationWaiters( + _ inFlight: AgentChatObservationInFlight, + returning value: Bool + ) { + for waiter in inFlight.waiters.values { + waiter.timer?.cancel() + waiter.continuation.resume(returning: value) + } + } + + private nonisolated static func timeInterval(for duration: Duration) -> TimeInterval { + let components = duration.components + let seconds = TimeInterval(components.seconds) + let fractional = TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000 + return max(0, seconds + fractional) + } + + private func observeAgentProcessesTask(scope: AgentChatObservationScope, force: Bool) -> AgentChatObservationHandle? { + if let inFlight = observeInFlight, + inFlight.scope.covers(scope) { + return inFlight.handle + } + if !force, + let observeLastStartedAt { + let elapsed = Date().timeIntervalSince(observeLastStartedAt) + if elapsed < Self.observeThrottleInterval { + return nil + } + } + observeLastStartedAt = Date() + let id = UUID() + let scanTask = Task.detached { + Self.scanObservedAgentSessions(onlySurfaceIDs: scope.surfaceIDs) + } + let task = Task { @MainActor [weak self] in + let observed = await withTaskCancellationHandler { + await scanTask.value + } onCancel: { + scanTask.cancel() + } + guard !Task.isCancelled, + let self, + self.observeInFlight?.id == id else { return } + self.applyObservedSessions(observed) + self.finishAgentProcessObservation(id: id) + } + let inFlight = AgentChatObservationInFlight(id: id, scope: scope, task: task) + replaceAgentProcessObservation(with: inFlight) + return inFlight.handle + } + + /// Off-main: one entry per distinct live codex/claude session under any cmux + /// surface, identity resolved without hooks. + private nonisolated static func scanObservedAgentSessions( + onlySurfaceIDs surfaceIDs: Set? = nil + ) -> [ObservedAgentSession] { + guard !Task.isCancelled else { return [] } + let snapshot = CmuxTopProcessSnapshot.capture( + includeProcessDetails: true, + includeCMUXScope: true + ) + guard !Task.isCancelled else { return [] } + return scanObservedAgentSessions( + in: snapshot, + onlySurfaceIDs: surfaceIDs, + processArgumentsAndEnvironment: CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for:), + codexRolloutPath: openCodexRolloutPath(pid:) + ) + } + + nonisolated static func scanObservedAgentSessions( + in snapshot: CmuxTopProcessSnapshot, + onlySurfaceIDs surfaceIDs: Set? = nil, + processArgumentsAndEnvironment: (Int) -> CmuxTopProcessArguments?, + codexRolloutPath: (Int) -> String? + ) -> [ObservedAgentSession] { + struct Candidate { + let session: ObservedAgentSession + let depth: Int + } + + var candidateBySessionID: [String: Candidate] = [:] + var rootPIDsBySurfaceID: [UUID: Set] = [:] + func rootPIDs(for surfaceID: UUID) -> Set { + if let cached = rootPIDsBySurfaceID[surfaceID] { return cached } + let roots = cmuxSurfaceRootPIDs(surfaceID: surfaceID, snapshot: snapshot) + rootPIDsBySurfaceID[surfaceID] = roots + return roots + } + for process in snapshot.cmuxScopedProcesses() { + if Task.isCancelled { return [] } + var details: CmuxTopProcessArguments? + func loadDetails() -> CmuxTopProcessArguments? { + if details == nil { + details = processArgumentsAndEnvironment(process.pid) + } + return details + } + guard process.isTerminalForegroundProcessGroup, + let surfaceID = process.cmuxSurfaceID, + surfaceIDs.map({ $0.contains(surfaceID) }) ?? true else { continue } + let rootPIDs = rootPIDs(for: surfaceID) + guard let def = codingAgentDefinition( + for: process, + allowLaunchKindEnvironment: allowsLaunchKindEnvironment( + for: process, + rootPIDs: rootPIDs, + arguments: rootPIDs.contains(process.pid) ? nil : loadDetails()?.arguments + ), + processArgumentsAndEnvironment: { _ in loadDetails() } + ), + def.id == "codex" || def.id == "claude" else { continue } + let loadedDetails = loadDetails() + let argv = loadedDetails?.arguments + let isClaudeForkLaunch = def.id == "claude" && argv.map(Self.containsClaudeForkSessionOption(_:)) == true + var sessionID: String? + var transcriptPath: String? + if def.id == "codex", let rollout = codexRolloutPath(process.pid) { + transcriptPath = rollout + sessionID = firstUUIDLike(in: (rollout as NSString).lastPathComponent) + } + if def.id == "claude", + !isClaudeForkLaunch, + let envSessionID = loadedDetails?.environment["CLAUDE_CODE_SESSION_ID"], + let id = firstUUIDLike(in: envSessionID) { + sessionID = id + } + if sessionID == nil, let argv, !isClaudeForkLaunch { + sessionID = sessionIDFromArguments(argv) + } + let explicitSessionOption = !isClaudeForkLaunch + && (argv.map(containsExplicitSessionOption(_:)) ?? false) + guard let resolved = sessionID ?? (def.id == "claude" && !explicitSessionOption ? pendingClaudeSessionID(surfaceID: surfaceID.uuidString) : nil) else { continue } + let candidate = Candidate( + session: ObservedAgentSession( + sessionID: resolved, + agentKind: ChatAgentKind(source: def.id), + surfaceID: surfaceID.uuidString, + workspaceID: process.cmuxWorkspaceID?.uuidString, + pid: process.pid, + workingDirectory: observedWorkingDirectory(details?.environment), + transcriptPath: transcriptPath, + sampledAt: snapshot.sampledAt + ), + depth: processTreeDepth(pid: process.pid, rootPIDs: rootPIDs, snapshot: snapshot) + ) + if let current = candidateBySessionID[resolved] { + let preferred = preferredLiveAgentPID( + current: (current.session.pid, current.depth), + candidate: (candidate.session.pid, candidate.depth) + ) + if preferred.pid == candidate.session.pid { + candidateBySessionID[resolved] = candidate + } + } else { + candidateBySessionID[resolved] = candidate + } + } + return candidateBySessionID.values.map(\.session).sorted { $0.pid < $1.pid } + } + + nonisolated static func allowsLaunchKindEnvironment( + for process: CmuxTopProcessInfo, + rootPIDs: Set, + arguments: [String]? + ) -> Bool { + if rootPIDs.contains(process.pid) { + return true + } + guard process.isTerminalForegroundProcessGroup, + process.processGroupID == process.pid, + let arguments else { + return false + } + if CmuxTaskManagerCodingAgentDefinition.matchingDefinition( + processName: process.name, + processPath: process.path, + arguments: arguments, + environment: [:] + ) != nil { + return true + } + return arguments.dropFirst().contains { argument in + normalizedObserverValue(argument)?.contains("/.cmux-agent-wrapper/") == true + } + } + + nonisolated static func codingAgentDefinition( + for process: CmuxTopProcessInfo, + allowLaunchKindEnvironment: Bool, + processArgumentsAndEnvironment: (Int) -> CmuxTopProcessArguments? + ) -> CmuxTaskManagerCodingAgentDefinition? { + let shouldReadDetails = CmuxTaskManagerCodingAgentDefinition.shouldReadArguments( + processName: process.name, + processPath: process.path + ) + if let direct = authoritativeCodingAgentDefinition( + processName: process.name, + processPath: process.path, + arguments: [], + environment: [:], + allowLaunchKindEnvironment: false + ) { + return direct + } + if !shouldReadDetails { return nil } + guard let details = processArgumentsAndEnvironment(process.pid) else { + return nil + } + return authoritativeCodingAgentDefinition( + processName: process.name, + processPath: process.path, + arguments: details.arguments, + environment: details.environment, + allowLaunchKindEnvironment: allowLaunchKindEnvironment + ) + } + + private nonisolated static func authoritativeCodingAgentDefinition( + processName: String, + processPath: String?, + arguments: [String], + environment: [String: String], + allowLaunchKindEnvironment: Bool + ) -> CmuxTaskManagerCodingAgentDefinition? { + let definitions = CmuxTaskManagerCodingAgentDefinition.builtIns + if allowLaunchKindEnvironment, + let launchKind = normalizedObserverValue(environment["CMUX_AGENT_LAUNCH_KIND"]), + let def = definitions.first(where: { $0.launchKinds.contains(launchKind) }) { + return def + } + let basenames = Set([processName, processPath, arguments.first].compactMap(observerBasename)) + if let def = definitions.first(where: { def in basenames.contains { def.directBasenames.contains($0) } }) { + return def + } + guard let path = normalizedObserverValue(processPath) else { return nil } + return definitions.first { def in + def.argumentNeedles.contains { needle in + guard needle.hasSuffix("/"), + let normalizedNeedle = normalizedObserverValue(needle) else { return false } + return path.contains(normalizedNeedle) + } + } + } + + private nonisolated static func observerBasename(_ value: String?) -> String? { + normalizedObserverValue(value.map { ($0 as NSString).lastPathComponent }) + } + + private nonisolated static func normalizedObserverValue(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed?.isEmpty == false ? trimmed : nil + } + + private nonisolated static func observedWorkingDirectory(_ environment: [String: String]?) -> String? { + guard let environment else { return nil } + for key in ["CMUX_AGENT_LAUNCH_CWD", "PWD"] { + if let value = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty { + return value + } + } + return nil + } + + private func endedPendingClaudeSessionHasHistoryIdentity(_ record: AgentChatSessionRecord) -> Bool { + record.transcriptPath != nil || record.hookStoreSessionID != nil + } + + private func observationCanReviveEndedSession( + current: AgentChatSessionRecord, + observed session: ObservedAgentSession + ) -> Bool { + guard current.state == .ended, current.pid != session.pid else { + return false + } + return session.sampledAt >= (current.endedAt ?? current.lastActivityAt) + } + + nonisolated static func sessionIDFromArguments(_ arguments: [String]) -> String? { + var index = 0 + while index < arguments.count { + let arg = arguments[index] + if ["--session-id", "--resume", "-r"].contains(arg), + index + 1 < arguments.count, + let id = sessionIDFromOptionValue(arguments[index + 1]) { + return id + } + for prefix in ["--session-id=", "--resume=", "-r="] where arg.hasPrefix(prefix) { + if let id = sessionIDFromOptionValue(String(arg.dropFirst(prefix.count))) { + return id + } + } + index += 1 + } + return nil + } + + private nonisolated static func containsExplicitSessionOption(_ arguments: [String]) -> Bool { + arguments.contains { argument in + argument == "--session-id" + || argument == "--resume" + || argument == "-r" + || argument.hasPrefix("--session-id=") + || argument.hasPrefix("--resume=") + || argument.hasPrefix("-r=") + } + } + nonisolated static func containsClaudeForkSessionOption(_ arguments: [String]) -> Bool { + arguments.contains { argument in + let value = argument.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return value == "--fork-session" || value.hasPrefix("--fork-session=") + } + } + + private nonisolated static func sessionIDFromOptionValue(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.hasPrefix("-") else { return nil } + return firstUUIDLike(in: trimmed) + } + /// libproc: the path of a `~/.codex/sessions/**/rollout-*.jsonl` the process + /// holds open (codex keeps its rollout open for writing), or nil. + nonisolated static func openCodexRolloutPath(pid: Int) -> String? { + let listSize = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, nil, 0) + guard listSize > 0 else { return nil } + let count = Int(listSize) / MemoryLayout.stride + guard count > 0 else { return nil } + var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: count) + let used = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, &fds, listSize) + guard used > 0 else { return nil } + let actual = Int(used) / MemoryLayout.stride + for index in 0...size) + ) + guard size > 0 else { continue } + let path = withUnsafeBytes(of: &info.pvip.vip_path) { raw -> String in + guard let base = raw.baseAddress else { return "" } + return String(cString: base.assumingMemoryBound(to: CChar.self)) + } + if path.hasSuffix(".jsonl"), path.contains("/.codex/sessions/") { + return path + } + } + return nil + } + + private nonisolated static let uuidLikeRegex = try? NSRegularExpression( + pattern: "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + ) + + /// The first UUID-shaped substring (matches both standard UUIDs and codex's + /// UUIDv7 rollout ids), or nil. + nonisolated static func firstUUIDLike(in string: String) -> String? { + guard let regex = uuidLikeRegex else { return nil } + let range = NSRange(string.startIndex..., in: string) + guard let match = regex.firstMatch(in: string, options: [], range: range), + let matchRange = Range(match.range, in: string) else { return nil } + return String(string[matchRange]) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index f5cd1abf0c69..9a67825f7c5a 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -2,32 +2,20 @@ import CMUXAgentLaunch import CmuxAgentChat import Foundation -/// A coding-agent session discovered by observing the process table, with no -/// dependency on hooks firing. Identity (and, for codex, the transcript path) -/// comes from the agent's own argv or open transcript file, so a session -/// launched through any indirection (a subrouter, a wrapper) is still found. -nonisolated struct ObservedAgentSession: Sendable { - let sessionID: String - let agentKind: ChatAgentKind - let surfaceID: String - let workspaceID: String? - let pid: Int - let transcriptPath: String? -} - /// Main-actor registry of chat-capable agent sessions, built from agent /// hook events and the on-disk hook session stores. @MainActor final class AgentChatSessionRegistry { private var records: [String: AgentChatSessionRecord] = [:] private var liveSessionIDBySurfaceID: [String: String] = [:] + private var liveClaudeSessionIDsBySurfaceID: [String: Set] = [:] private let hookStore: AgentChatHookSessionStore /// Called after a record mutation with the previous value (nil for a /// brand-new record), so the owner derives state/descriptor deltas in /// one place instead of hand-maintained flags. var onRecordChanged: ((AgentChatSessionRecord, _ previous: AgentChatSessionRecord?) -> Void)? - + var onRecordRemoved: ((AgentChatSessionRecord) -> Void)? /// Per-session timestamp of the last hook-store file consult, bounding /// main-actor disk reads during tool storms. private var hookStoreConsultedAt: [String: Date] = [:] @@ -108,96 +96,75 @@ final class AgentChatSessionRegistry { source.resume() } - /// Observe-floor liveness: the pid of a live agent process matching `kind` - /// anywhere under `surfaceID`'s process tree, or nil if none. - /// - /// A launcher or intermediate process (a subrouter like `sr`, a `node` - /// shim) is NOT the agent; the real agent binary (e.g. `codex`, `claude`) - /// appears deeper in the tree. So liveness must be judged from the whole - /// process tree under the surface, never from a single recorded pid that may - /// be a launcher. Nonisolated and snapshot-based so it runs off the main - /// actor; callers hop back to the main actor to apply the result. The - /// classifier matches by process basename, so only the real agent binary - /// matches (a `node …/codex` shim is named `node` and does not). - private nonisolated static func liveAgentPID(surfaceID: String, kind: ChatAgentKind) -> Int? { - guard let surfaceUUID = UUID(uuidString: surfaceID) else { return nil } - let snapshot = CmuxTopProcessSnapshot.capture( - includeProcessDetails: true, - includeCMUXScope: true - ) - let rootPIDs = snapshot.pids(forCMUXSurfaceID: surfaceUUID) - guard !rootPIDs.isEmpty else { return nil } - let wantedID = kind.sourceName - for pid in snapshot.expandedPIDs(rootPIDs: rootPIDs).sorted() { - guard let info = snapshot.process(pid: pid), - let def = CmuxTaskManagerCodingAgentDefinition.matchingDefinition( - processName: info.name, - processPath: info.path, - arguments: [], - environment: [:] - ), - def.id == wantedID else { continue } - return pid - } - return nil - } - - // MARK: Observe-floor detection (process tree) - - /// Off-main scan + main-actor apply: discover live codex/claude sessions by - /// observing the process table, with no dependency on hooks firing. Resolves - /// identity from the agent's own state (codex: the rollout file it holds - /// open; claude: its `--session-id`/`--resume` argv), so a session launched - /// through any indirection (a subrouter, a wrapper) is still found and bound. - /// Throttled; safe to call coarsely (e.g. on the iOS list pull). The snapshot - /// is captured off the main actor. - private var observeThrottle: Date? - func observeAgentProcesses() async { - let now = Date() - if let last = observeThrottle, now.timeIntervalSince(last) < 2.0 { return } - observeThrottle = now - let observed = await Task.detached { Self.scanObservedAgentSessions() }.value - applyObservedSessions(observed) - } + var observeInFlight: AgentChatObservationInFlight? + var observeLastStartedAt: Date? + static let observeThrottleInterval: TimeInterval = 2 /// Folds detections in: create a record for any session not already known /// (state `.idle`, from cmux's own observation), and backfill a missing /// binding (surface / workspace / transcript / pid) on an existing one. /// Observation only ADDS presence and bindings; it never downgrades /// hook-derived state. - private func applyObservedSessions(_ observed: [ObservedAgentSession]) { + func applyObservedSessions(_ observed: [ObservedAgentSession]) { let now = Date() for session in observed { + let canonicalSessionID = canonicalClaudeSessionID(incomingSessionID: session.sessionID, source: session.agentKind.sourceName, surfaceID: session.surfaceID) + let targetSessionID = observedClaudeSessionID(canonicalSessionID: canonicalSessionID, observed: session) + let observedHasRealHookStoreIdentity = session.agentKind == .claude + && !Self.isPendingClaudeSessionID(session.sessionID) #if DEBUG cmuxDebugLog( - "agentChat.detect session=\(session.sessionID.prefix(8)) kind=\(session.agentKind.sourceName) " + "agentChat.detect session=\(targetSessionID.prefix(8)) kind=\(session.agentKind.sourceName) " + "surface=\(session.surfaceID.prefix(8)) pid=\(session.pid) " + "transcript=\(session.transcriptPath != nil ? "fd" : "argv-only") " - + "\(records[session.sessionID] == nil ? "new" : "bind-existing")" + + "\(records[targetSessionID] == nil ? "new" : "bind-existing")" ) #endif - if records[session.sessionID] == nil { + if records[targetSessionID] == nil { var record = AgentChatSessionRecord( - sessionID: session.sessionID, + sessionID: targetSessionID, agentKind: session.agentKind, workspaceID: session.workspaceID, surfaceID: session.surfaceID, - workingDirectory: nil, + workingDirectory: session.workingDirectory, transcriptPath: session.transcriptPath, state: .idle, lastActivityAt: now, title: nil, pid: session.pid ) + if observedHasRealHookStoreIdentity { + record.rememberHookStoreSessionID(session.sessionID) + } + stampLifecycleTransition(previous: nil, current: &record, at: session.sampledAt) stampVersion(&record) - records[session.sessionID] = record + records[targetSessionID] = record syncProcessExitWatch(for: record) updateLiveSessionIndex(previous: nil, current: record) onRecordChanged?(record, nil) } else { - update(sessionID: session.sessionID) { rec in + guard let current = records[targetSessionID] else { continue } + if reviveEndedObservedSessionIfNeeded(current: current, observed: session, now: now) { + continue + } + let needsBackfill = current.surfaceID == nil + || (current.workspaceID == nil && session.workspaceID != nil) + || (current.workingDirectory == nil && session.workingDirectory != nil) + || (current.transcriptPath == nil && session.transcriptPath != nil) + || current.pid == nil + || ( + observedHasRealHookStoreIdentity + && targetSessionID != session.sessionID + && current.hookStoreSessionID != session.sessionID + ) + guard needsBackfill else { continue } + update(sessionID: targetSessionID) { rec in + if observedHasRealHookStoreIdentity, targetSessionID != session.sessionID { + rec.rememberHookStoreSessionID(session.sessionID) + } if rec.surfaceID == nil { rec.surfaceID = session.surfaceID } if rec.workspaceID == nil { rec.workspaceID = session.workspaceID } + if rec.workingDirectory == nil { rec.workingDirectory = session.workingDirectory } if rec.transcriptPath == nil { rec.transcriptPath = session.transcriptPath } if rec.pid == nil { rec.pid = session.pid } } @@ -205,118 +172,6 @@ final class AgentChatSessionRegistry { } } - /// Off-main: one entry per distinct live codex/claude session under any cmux - /// surface, identity resolved without hooks. - private nonisolated static func scanObservedAgentSessions() -> [ObservedAgentSession] { - let snapshot = CmuxTopProcessSnapshot.capture( - includeProcessDetails: true, - includeCMUXScope: true - ) - var result: [ObservedAgentSession] = [] - var seen = Set() - for process in snapshot.cmuxScopedProcesses() { - guard let surfaceID = process.cmuxSurfaceID, - let def = CmuxTaskManagerCodingAgentDefinition.matchingDefinition( - processName: process.name, - processPath: process.path, - arguments: [], - environment: [:] - ), - def.id == "codex" || def.id == "claude" else { continue } - var sessionID: String? - var transcriptPath: String? - if def.id == "codex", let rollout = openCodexRolloutPath(pid: process.pid) { - transcriptPath = rollout - sessionID = firstUUIDLike(in: (rollout as NSString).lastPathComponent) - } - if sessionID == nil, - let argv = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process.pid)?.arguments { - sessionID = sessionIDFromArguments(argv) - } - guard let resolved = sessionID, !seen.contains(resolved) else { continue } - seen.insert(resolved) - result.append(ObservedAgentSession( - sessionID: resolved, - agentKind: ChatAgentKind(source: def.id), - surfaceID: surfaceID.uuidString, - workspaceID: process.cmuxWorkspaceID?.uuidString, - pid: process.pid, - transcriptPath: transcriptPath - )) - } - return result - } - - /// libproc: the path of a `~/.codex/sessions/**/rollout-*.jsonl` the process - /// holds open (codex keeps its rollout open for writing), or nil. - private nonisolated static func openCodexRolloutPath(pid: Int) -> String? { - let listSize = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, nil, 0) - guard listSize > 0 else { return nil } - let count = Int(listSize) / MemoryLayout.stride - guard count > 0 else { return nil } - var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: count) - let used = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, &fds, listSize) - guard used > 0 else { return nil } - let actual = Int(used) / MemoryLayout.stride - for index in 0...size) - ) - guard size > 0 else { continue } - let path = withUnsafeBytes(of: &info.pvip.vip_path) { raw -> String in - guard let base = raw.baseAddress else { return "" } - return String(cString: base.assumingMemoryBound(to: CChar.self)) - } - if path.hasSuffix(".jsonl"), path.contains("/.codex/sessions/") { - return path - } - } - return nil - } - - /// Extracts a session id from an agent's argv (`--session-id `, - /// `--session-id=`, `--resume `, `--resume=`). - private nonisolated static func sessionIDFromArguments(_ arguments: [String]) -> String? { - var index = 0 - while index < arguments.count { - let arg = arguments[index] - if arg == "--session-id" || arg == "--resume", index + 1 < arguments.count, - let id = firstUUIDLike(in: arguments[index + 1]) { - return id - } - if arg.hasPrefix("--session-id="), - let id = firstUUIDLike(in: String(arg.dropFirst("--session-id=".count))) { - return id - } - if arg.hasPrefix("--resume="), - let id = firstUUIDLike(in: String(arg.dropFirst("--resume=".count))) { - return id - } - index += 1 - } - return nil - } - - private nonisolated static let uuidLikeRegex = try? NSRegularExpression( - pattern: "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" - ) - - /// The first UUID-shaped substring (matches both standard UUIDs and codex's - /// UUIDv7 rollout ids), or nil. - private nonisolated static func firstUUIDLike(in string: String) -> String? { - guard let regex = uuidLikeRegex else { return nil } - let range = NSRange(string.startIndex..., in: string) - guard let match = regex.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range, in: string) else { return nil } - return String(string[matchRange]) - } - /// The watched agent process exited. Before ending the session, verify /// against the surface's process tree off-main: the dead pid may be a /// launcher/intermediate (subrouter, `node` shim) while the real agent still @@ -333,8 +188,14 @@ final class AgentChatSessionRegistry { return } let kind = record.agentKind + let expectedSessionIDs = Set([record.sessionID, record.hookStoreLookupSessionID]) Task.detached { [weak self] in - let livePID = Self.liveAgentPID(surfaceID: surfaceID, kind: kind) + let livePID = Self.liveAgentPID( + surfaceID: surfaceID, + kind: kind, + matchingSessionIDs: expectedSessionIDs, + allowUnidentifiedFallback: Self.allowsUnidentifiedClaudeLivenessFallback(for: record) + ) await MainActor.run { [weak self] in guard let self, let current = self.records[sessionID], @@ -351,6 +212,12 @@ final class AgentChatSessionRegistry { } } + nonisolated static func allowsUnidentifiedClaudeLivenessFallback(for record: AgentChatSessionRecord) -> Bool { + record.agentKind == .claude + && isPendingClaudeSessionID(record.sessionID) + && record.hookStoreSessionID == nil + } + /// One session's record. /// /// - Parameter sessionID: Raw (unprefixed) session id. @@ -395,10 +262,10 @@ final class AgentChatSessionRegistry { func refreshBindingsFromHookStore(sessionID: String) async -> AgentChatSessionRecord? { guard let record = records[sessionID] else { return nil } let store = hookStore - let source = record.agentKind.sourceName + let source = record.agentKind.sourceName, lookupSessionID = record.hookStoreLookupSessionID // Whole-file JSON read+parse off the main actor. let entry = await Task.detached(priority: .utility) { - store.entry(agentSource: source, sessionID: sessionID) + store.entry(agentSource: source, sessionID: lookupSessionID) }.value guard let entry else { return records[sessionID] } update(sessionID: sessionID) { $0.adoptBindings(from: entry, includingPID: false) } @@ -418,6 +285,7 @@ final class AgentChatSessionRegistry { guard let previous = records[sessionID] else { return } var record = previous mutate(&record) + stampLifecycleTransition(previous: previous, current: &record, at: Date()) stampVersion(&record) records[sessionID] = record #if DEBUG @@ -471,10 +339,27 @@ final class AgentChatSessionRegistry { for (source, entries) in parsed { let kind = ChatAgentKind(source: source) for entry in entries { - guard records[entry.sessionID] == nil else { continue } + let sessionID = canonicalClaudeSessionID( + incomingSessionID: entry.sessionID, + source: source, + surfaceID: entry.surfaceID, + context: .hookStoreSeed(entry) + ) + if let current = records[sessionID] { + var candidate = current + candidate.adoptMissingBindings(from: entry, includingPID: false) + guard candidate.surfaceID != current.surfaceID + || candidate.workspaceID != current.workspaceID + || candidate.transcriptPath != current.transcriptPath + || candidate.workingDirectory != current.workingDirectory || candidate.hookStoreSessionID != current.hookStoreSessionID else { continue } + update(sessionID: sessionID) { record in + record.adoptMissingBindings(from: entry, includingPID: false) + } + continue + } let alive = entry.pid.map { kill(pid_t($0), 0) == 0 } ?? false var record = AgentChatSessionRecord( - sessionID: entry.sessionID, + sessionID: sessionID, agentKind: kind, workspaceID: entry.workspaceID, surfaceID: entry.surfaceID, @@ -485,10 +370,13 @@ final class AgentChatSessionRegistry { title: nil, pid: entry.pid ) + record.rememberHookStoreSessionID(entry.sessionID) + stampLifecycleTransition(previous: nil, current: &record, at: entry.updatedAt ?? Date()) stampVersion(&record) - records[entry.sessionID] = record + records[sessionID] = record syncProcessExitWatch(for: record) updateLiveSessionIndex(previous: nil, current: record) + onRecordChanged?(record, nil) } } } @@ -500,7 +388,13 @@ final class AgentChatSessionRegistry { /// - Returns: The up-to-date record. @discardableResult func noteHookEvent(_ event: WorkstreamEvent) -> AgentChatSessionRecord { - let sessionID = Self.normalizedSessionID(event.sessionId, source: event.source) + let hookSessionID = Self.normalizedSessionID(event.sessionId, source: event.source) + let sessionID = canonicalClaudeSessionID( + incomingSessionID: hookSessionID, + source: event.source, + surfaceID: event.surfaceId, + context: .liveEvidence + ) let kind = ChatAgentKind(source: event.source) #if DEBUG cmuxDebugLog( @@ -523,6 +417,7 @@ final class AgentChatSessionRegistry { title: nil, pid: nil ) + record.rememberHookStoreSessionID(hookSessionID) if event.hookEventName == .sessionStart { // A resumed session (claude --resume reuses session ids) runs // under a NEW process; the old pid would make the liveness @@ -564,17 +459,122 @@ final class AgentChatSessionRegistry { let previous = records[sessionID] record.state = Self.nextState(previous: record.state, event: event) + stampLifecycleTransition(previous: previous, current: &record, at: event.receivedAt) stampVersion(&record) records[sessionID] = record syncProcessExitWatch(for: record) updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) if shouldConsultStore { - backfillBindingsFromStore(sessionID: sessionID, agentSource: event.source) + backfillBindingsFromStore( + sessionID: sessionID, + lookupSessionID: hookSessionID, + agentSource: event.source + ) } return record } + private func canonicalClaudeSessionID( + incomingSessionID: String, + source: String, + surfaceID: String?, + context: ClaudeSessionCanonicalizationContext = .liveEvidence + ) -> String { + guard source == "claude", + let surfaceID else { + return incomingSessionID + } + if Self.isPendingClaudeSessionID(incomingSessionID) { + return liveClaudeSessionID(surfaceID: surfaceID, pending: false, excluding: incomingSessionID) + ?? incomingSessionID + } + if records[incomingSessionID] != nil { + if case .liveEvidence = context { + removeLivePendingClaudeAliases(surfaceID: surfaceID, excluding: incomingSessionID) + } + return incomingSessionID + } + let pendingID: String? + switch context { + case .liveEvidence: + pendingID = liveClaudeSessionID(surfaceID: surfaceID, pending: true, excluding: incomingSessionID) + case .hookStoreSeed(let entry): + pendingID = currentPendingClaudeAlias( + surfaceID: surfaceID, + incomingSessionID: incomingSessionID, + hookStoreEntry: entry + ) + } + if let pendingID { + return pendingID + } + return incomingSessionID + } + + private func currentPendingClaudeAlias( + surfaceID: String, + incomingSessionID: String, + hookStoreEntry entry: AgentChatHookSessionStore.Entry + ) -> String? { + liveClaudeRecords(surfaceID: surfaceID, pending: true, excluding: incomingSessionID) + .filter { record in + record.hookStoreSessionID == entry.sessionID + || (entry.pid != nil && record.pid == entry.pid) + } + .max(by: { $0.lastActivityAt < $1.lastActivityAt })? + .sessionID + } + + private func liveClaudeSessionID( + surfaceID: String, + pending: Bool, + excluding excludedSessionID: String? + ) -> String? { + liveClaudeRecords(surfaceID: surfaceID, pending: pending, excluding: excludedSessionID) + .max(by: { $0.lastActivityAt < $1.lastActivityAt })? + .sessionID + } + + private func removeLivePendingClaudeAliases(surfaceID: String, excluding excludedSessionID: String?) { + let aliases = liveClaudeRecords(surfaceID: surfaceID, pending: true, excluding: excludedSessionID) + .map(\.sessionID) + guard !aliases.isEmpty else { return } + for alias in aliases { + guard var record = records.removeValue(forKey: alias) else { continue } + stampVersion(&record) + exitWatchers[alias]?.source.cancel() + exitWatchers[alias] = nil + hookStoreConsultedAt.removeValue(forKey: alias) + updateLiveSessionIndex(previous: record, current: nil) + onRecordRemoved?(record) + } + if let indexed = liveSessionIDBySurfaceID[surfaceID], + aliases.contains(indexed) { + liveSessionIDBySurfaceID.removeValue(forKey: surfaceID) + rebuildLiveSessionIndex(surfaceID: surfaceID) + } + } + + private func liveClaudeRecords( + surfaceID: String, + pending: Bool, + excluding excludedSessionID: String? + ) -> [AgentChatSessionRecord] { + guard let sessionIDs = liveClaudeSessionIDsBySurfaceID[surfaceID] else { return [] } + return sessionIDs.compactMap { sessionID in + guard sessionID != excludedSessionID, + let record = records[sessionID], + record.agentKind == .claude, + record.surfaceID == surfaceID, + record.state != .ended, + Self.isPendingClaudeSessionID(record.sessionID) == pending else { + return nil + } + return record + } + } + /// Records, from cmux's own authority, that it is resuming `rawSessionID` /// onto `surfaceID`. Resume is ALWAYS cmux-initiated, and some agents (codex) /// fire NO SessionStart hook on resume, so the hook-driven path would keep the @@ -633,6 +633,7 @@ final class AgentChatSessionRegistry { title: nil, pid: nil ) + stampLifecycleTransition(previous: nil, current: &record, at: now) stampVersion(&record) records[sessionID] = record syncProcessExitWatch(for: record) @@ -646,11 +647,15 @@ final class AgentChatSessionRegistry { /// re-tails and pushes if the transcript path just became known. Filling /// only nil fields keeps the live event authoritative over the lagging /// store. - private func backfillBindingsFromStore(sessionID: String, agentSource: String) { + private func backfillBindingsFromStore( + sessionID: String, + lookupSessionID: String, + agentSource: String + ) { let store = hookStore Task { [weak self] in let entry = await Task.detached(priority: .utility) { - store.entry(agentSource: agentSource, sessionID: sessionID) + store.entry(agentSource: agentSource, sessionID: lookupSessionID) }.value guard let self, let entry else { return } self.applyStoreBackfill(sessionID: sessionID, entry: entry) @@ -663,21 +668,22 @@ final class AgentChatSessionRegistry { private func applyStoreBackfill(sessionID: String, entry: AgentChatHookSessionStore.Entry) { guard let current = records[sessionID] else { return } var candidate = current - candidate.adoptBindings(from: entry, includingPID: current.pid == nil) + candidate.adoptMissingBindings(from: entry, includingPID: current.pid == nil) guard candidate.surfaceID != current.surfaceID || candidate.workspaceID != current.workspaceID || candidate.transcriptPath != current.transcriptPath || candidate.workingDirectory != current.workingDirectory - || candidate.pid != current.pid else { return } + || candidate.pid != current.pid || candidate.hookStoreSessionID != current.hookStoreSessionID else { return } update(sessionID: sessionID) { record in - record.adoptBindings(from: entry, includingPID: record.pid == nil) + record.adoptMissingBindings(from: entry, includingPID: record.pid == nil) } } private func updateLiveSessionIndex( previous: AgentChatSessionRecord?, - current: AgentChatSessionRecord + current: AgentChatSessionRecord? ) { + updateLiveClaudeSessionIndex(previous: previous, current: current) let previousSurfaceID = Self.liveSurfaceID(previous) let currentSurfaceID = Self.liveSurfaceID(current) if let previousSurfaceID, @@ -686,7 +692,7 @@ final class AgentChatSessionRegistry { liveSessionIDBySurfaceID.removeValue(forKey: previousSurfaceID) rebuildLiveSessionIndex(surfaceID: previousSurfaceID) } - guard let currentSurfaceID else { return } + guard let current, let currentSurfaceID else { return } guard let indexedSessionID = liveSessionIDBySurfaceID[currentSurfaceID], let indexed = records[indexedSessionID], indexed.surfaceID == currentSurfaceID, @@ -699,6 +705,22 @@ final class AgentChatSessionRegistry { } } + private func updateLiveClaudeSessionIndex( + previous: AgentChatSessionRecord?, + current: AgentChatSessionRecord? + ) { + let previousSurfaceID = Self.liveClaudeSurfaceID(previous) + let currentSurfaceID = Self.liveClaudeSurfaceID(current) + if let previousSurfaceID, previousSurfaceID != currentSurfaceID { + liveClaudeSessionIDsBySurfaceID[previousSurfaceID]?.remove(previous?.sessionID ?? "") + if liveClaudeSessionIDsBySurfaceID[previousSurfaceID]?.isEmpty == true { + liveClaudeSessionIDsBySurfaceID.removeValue(forKey: previousSurfaceID) + } + } + guard let current, let currentSurfaceID else { return } + liveClaudeSessionIDsBySurfaceID[currentSurfaceID, default: []].insert(current.sessionID) + } + private func rebuildLiveSessionIndex(surfaceID: String?) { guard let surfaceID else { return } if let newest = records.values @@ -717,45 +739,15 @@ final class AgentChatSessionRegistry { return record.surfaceID } - private func processIsDead(_ pid: Int) -> Bool { - kill(pid_t(pid), 0) != 0 && errno == ESRCH - } - - /// Strips an agent-name prefix from prefixed workstream ids - /// (`claude-`); raw hook ids pass through. - private static func normalizedSessionID(_ id: String, source: String) -> String { - let prefix = "\(source)-" - if id.hasPrefix(prefix) { - return String(id.dropFirst(prefix.count)) + private static func liveClaudeSurfaceID(_ record: AgentChatSessionRecord?) -> String? { + guard let record, record.agentKind == .claude, record.state != .ended else { + return nil } - return id + return record.surfaceID } - private static func nextState( - previous: ChatAgentState, - event: WorkstreamEvent - ) -> ChatAgentState { - switch event.hookEventName { - case .sessionStart: - return .idle - case .userPromptSubmit, .preToolUse, .postToolUse, .todoWrite: - if case .working = previous { return previous } - return .working(since: event.receivedAt) - case .preCompact, .postCompact: - // Compaction is lifecycle telemetry. It can occur while a session - // is idle, so it must not create a synthetic working state. - return previous - case .permissionRequest, .askUserQuestion, .exitPlanMode, .notification: - if case .needsInput = previous { return previous } - return .needsInput(since: event.receivedAt) - case .stop: - return .idle - case .subagentStart, .subagentStop: - // Task subagent lifecycle says nothing about the parent - // session's activity; keep the current state. - return previous - case .sessionEnd: - return .ended - } + private func processIsDead(_ pid: Int) -> Bool { + kill(pid_t(pid), 0) != 0 && errno == ESRCH } + } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index b54a0ab5dc57..e667be6aba3c 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -58,12 +58,8 @@ struct AgentChatTranscriptResolver: Sendable { /// - record: The session's registry record. /// - Returns: An existing transcript path, or `nil` when none is found. func transcriptPath(for record: AgentChatSessionRecord) -> String? { - let fileManager = FileManager.default - if let recorded = record.transcriptPath { - let expanded = (recorded as NSString).expandingTildeInPath - if fileManager.fileExists(atPath: expanded) { - return expanded - } + if let recorded = recordedTranscriptPath(for: record) { + return recorded } switch record.agentKind { case .claude: @@ -75,6 +71,28 @@ struct AgentChatTranscriptResolver: Sendable { } } + /// Resolves only paths that are cheap to check from the main-actor mobile + /// session list path. Codex's fallback scans the full sessions tree, so it is + /// intentionally excluded here and remains available only when opening a + /// transcript. + func boundedTranscriptPath(for record: AgentChatSessionRecord) -> String? { + if let recorded = recordedTranscriptPath(for: record) { + return recorded + } + switch record.agentKind { + case .claude: + return claudeFallbackPath(record: record) + case .codex, .other: + return nil + } + } + + private func recordedTranscriptPath(for record: AgentChatSessionRecord) -> String? { + guard let recorded = record.transcriptPath else { return nil } + let expanded = (recorded as NSString).expandingTildeInPath + return FileManager.default.fileExists(atPath: expanded) ? expanded : nil + } + private func claudeFallbackPath(record: AgentChatSessionRecord) -> String? { let fileManager = FileManager.default guard let cwd = record.workingDirectory else { return nil } @@ -82,7 +100,7 @@ struct AgentChatTranscriptResolver: Sendable { let path = claudeConfigRoot .appendingPathComponent("projects", isDirectory: true) .appendingPathComponent(projectDir, isDirectory: true) - .appendingPathComponent("\(record.sessionID).jsonl", isDirectory: false) + .appendingPathComponent("\(record.hookStoreLookupSessionID).jsonl", isDirectory: false) .path return fileManager.fileExists(atPath: path) ? path : nil } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+Wire.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+Wire.swift new file mode 100644 index 000000000000..2b5a84d004f3 --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService+Wire.swift @@ -0,0 +1,24 @@ +import CmuxAgentChat +import Foundation + +extension AgentChatTranscriptService { + static func descriptorChangedMeaningfully( + previous: AgentChatSessionRecord?, + current: AgentChatSessionRecord + ) -> Bool { + guard var normalizedPrevious = previous else { return true } + normalizedPrevious.lastActivityAt = current.lastActivityAt + return normalizedPrevious.descriptor != current.descriptor + } + + /// Encodes a wire value into the `[String: Any]` payload shape the + /// event fan-out expects. + func wirePayload(_ value: T) -> [String: Any]? { + let coding = ChatWireCoding() + guard let data = try? coding.encode(value), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return object + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index a32c19608d23..41d1ffad0cc4 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -13,14 +13,17 @@ final class AgentChatTranscriptService { let registry: AgentChatSessionRegistry let resolver: AgentChatTranscriptResolver - private let coding = ChatWireCoding() private var tailers: [String: AgentChatTranscriptTailer] = [:] + private let hasEventSubscribers: @MainActor () -> Bool + private let emitEventPayload: @MainActor ([String: Any]) -> Void + private let now: () -> Date /// Drives the live agent-prose streaming preview. private var proseStreamer: AgentChatProseStreamer! /// Sessions whose transcript could not be resolved; skipped until an /// explicit history request retries, so per-hook-event resolution /// failures don't rescan the filesystem during tool storms. private var failedResolutions: Set = [] + private var endedListability = AgentChatEndedTranscriptListabilityCache() /// Creates the service with a hook-store-backed registry. /// @@ -36,17 +39,30 @@ final class AgentChatTranscriptService { /// - resolver: Transcript path resolver. init( registry: AgentChatSessionRegistry, - resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver() + resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver(), + hasEventSubscribers: @escaping @MainActor () -> Bool = { + MobileHostService.hasEventSubscribers(topic: AgentChatTranscriptService.eventTopic) + }, + emitEventPayload: @escaping @MainActor ([String: Any]) -> Void = { payload in + MobileHostService.emitEvent(topic: AgentChatTranscriptService.eventTopic, payload: payload) + }, + now: @escaping () -> Date = { Date() } ) { self.registry = registry self.resolver = resolver + self.hasEventSubscribers = hasEventSubscribers + self.emitEventPayload = emitEventPayload + self.now = now registry.onRecordChanged = { [weak self] record, previous in self?.handleRecordChange(record, previous: previous) } + registry.onRecordRemoved = { [weak self] record in + self?.handleRecordRemoval(record) + } self.proseStreamer = AgentChatProseStreamer( emit: { [weak self] frame in self?.emit(frame: frame) }, snapshot: { surfaceID in Self.screenRows(surfaceID: surfaceID) }, - hasSubscribers: { MobileHostService.hasEventSubscribers(topic: Self.eventTopic) } + hasSubscribers: { [weak self] in self?.hasEventSubscribers() ?? false } ) } @@ -108,9 +124,9 @@ final class AgentChatTranscriptService { } } - /// Seeds the session registry from the on-disk hook stores. Call once - /// at app startup. Sessions are tracked only via the reliable hook-event - /// path thereafter; cmux does not detect agents that never fire a hook. + /// Seeds the session registry from the on-disk hook stores. Call once at + /// app startup. Hook events stay authoritative for state and transcripts; + /// observe-floor scans later add live agent presence even before hooks fire. func start() { Self.liveInstance = self // Apply resume re-binds buffered before the service was wired. The seed @@ -192,12 +208,19 @@ final class AgentChatTranscriptService { /// Observe-floor detection: discover live codex/claude sessions from the /// process table (no hooks required) and fold them into the registry. - /// Throttled; intended to run on the iOS list pull so a fresh detection - /// appears the moment the GUI asks for the list. + /// Awaitable for tests/debug paths that need the updated registry before + /// proceeding. func observeAgentProcesses() async { await registry.observeAgentProcesses() } + /// Waits briefly for one coalesced observe-floor scan before a list pull. + /// Returns false when the scan is still running at the deadline; the caller + /// can return the current registry snapshot and let the scan push deltas. + func observeAgentProcessesForListing(surfaceIDs: Set?, waitUpTo timeout: Duration) async -> Bool { + await registry.observeAgentProcessesForListing(surfaceIDs: surfaceIDs, waitUpTo: timeout) + } + /// The registry record for a session (send path needs the terminal /// binding). /// @@ -207,6 +230,25 @@ final class AgentChatTranscriptService { registry.record(sessionID: sessionID) } + /// Whether an ended session can still serve history without expensive + /// fallback scans. Live sessions stay visible before their JSONL exists; + /// ended sessions with missing JSONL only open to an unrecoverable error. + func hasBoundedReadableTranscript(_ record: AgentChatSessionRecord) -> Bool { + resolver.boundedTranscriptPath(for: record) != nil + } + + /// Whether an ended session should remain visible in the list. Claude can be + /// checked cheaply from cwd/recorded path; Codex fallback scans its sessions + /// tree, so Codex rows stay listable and resolve fallback history on open. + func shouldListEndedSession(_ record: AgentChatSessionRecord) -> Bool { + switch record.agentKind { + case .codex: + return true + case .claude, .other: + return endedListability.shouldList(record, resolver: resolver, now: now()) + } + } + /// Re-adopts one session's terminal bindings from the hook store; see /// ``AgentChatSessionRegistry/refreshBindingsFromHookStore(sessionID:)``. @discardableResult @@ -394,6 +436,14 @@ final class AgentChatTranscriptService { } private func handleRecordChange(_ record: AgentChatSessionRecord, previous: AgentChatSessionRecord?) { + let endedRecordIsListable: Bool + if record.state == .ended { + endedRecordIsListable = record.agentKind == .codex + || endedListability.update(record, previous: previous, resolver: resolver, now: now()) + } else { + endedListability.remove(sessionID: record.sessionID) + endedRecordIsListable = true + } let stateChanged = previous?.state != record.state let transcriptBecameAvailable = previous?.transcriptPath == nil && record.transcriptPath != nil if stateChanged, record.state == .ended { @@ -408,10 +458,14 @@ final class AgentChatTranscriptService { Task { await tailer.stop() } } } - guard MobileHostService.hasEventSubscribers(topic: Self.eventTopic) else { return } + guard hasEventSubscribers() else { return } if transcriptBecameAvailable, record.state != .ended { ensureTailer(for: record) } + if record.state == .ended, !endedRecordIsListable { + emit(frame: ChatSessionEventFrame(sessionID: record.sessionID, event: .sessionRemoved(version: record.version))) + return + } if stateChanged { emit(frame: ChatSessionEventFrame(sessionID: record.sessionID, event: .stateChanged(record.state))) } @@ -423,27 +477,19 @@ final class AgentChatTranscriptService { } } - private static func descriptorChangedMeaningfully( - previous: AgentChatSessionRecord?, - current: AgentChatSessionRecord - ) -> Bool { - guard var normalizedPrevious = previous else { return true } - normalizedPrevious.lastActivityAt = current.lastActivityAt - return normalizedPrevious.descriptor != current.descriptor + private func handleRecordRemoval(_ record: AgentChatSessionRecord) { + proseStreamer.turnEnded(sessionID: record.sessionID) + if let tailer = tailers.removeValue(forKey: record.sessionID) { + Task { await tailer.stop() } + } + failedResolutions.remove(record.sessionID) + endedListability.remove(sessionID: record.sessionID) + guard hasEventSubscribers() else { return } + emit(frame: ChatSessionEventFrame(sessionID: record.sessionID, event: .sessionRemoved(version: record.version))) } private func emit(frame: ChatSessionEventFrame) { guard let payload = wirePayload(frame) else { return } - MobileHostService.emitEvent(topic: Self.eventTopic, payload: payload) - } - - /// Encodes a wire value into the `[String: Any]` payload shape the - /// event fan-out expects. - func wirePayload(_ value: T) -> [String: Any]? { - guard let data = try? coding.encode(value), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - return object + emitEventPayload(payload) } } diff --git a/Sources/Mobile/AgentChat/ClaudeSessionCanonicalizationContext.swift b/Sources/Mobile/AgentChat/ClaudeSessionCanonicalizationContext.swift new file mode 100644 index 000000000000..a2d20361980f --- /dev/null +++ b/Sources/Mobile/AgentChat/ClaudeSessionCanonicalizationContext.swift @@ -0,0 +1,4 @@ +enum ClaudeSessionCanonicalizationContext { + case liveEvidence + case hookStoreSeed(AgentChatHookSessionStore.Entry) +} diff --git a/Sources/TaskManagerTypes.swift b/Sources/TaskManagerTypes.swift index f3818d4bc07c..20f025075452 100644 --- a/Sources/TaskManagerTypes.swift +++ b/Sources/TaskManagerTypes.swift @@ -542,7 +542,7 @@ struct CmuxTaskManagerCodingAgentDefinition: Equatable { displayName: "Claude Code", assetName: "AgentIcons/Claude", launchKinds: ["claude", "claudeteams", "claude-teams", "omc"], - directBasenames: ["claude", "claude-code", "claude_code", "claude-teams", "omc"], + directBasenames: ["claude", "claude.exe", "claude-code", "claude_code", "claude-teams", "omc"], argumentNeedles: [ "claude-code", "claude_code", diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 5409fb9b28df..9f8943f71237 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -32,7 +32,7 @@ extension TerminalController { func v2MobileChatDispatch(method: String, params: [String: Any]) async -> V2CallResult { switch method { case "mobile.chat.sessions": - return v2MobileChatSessions(params: params) + return await v2MobileChatSessions(params: params) case "mobile.chat.session": return v2MobileChatSession(params: params) case "mobile.chat.history": @@ -73,20 +73,21 @@ extension TerminalController { /// still matches its agent against THAT workspace+panel. Each returned /// session is re-stamped to W so its seed and live `descriptorChanged` /// pushes both scope to the current workspace. - func v2MobileChatSessions(params: [String: Any]) -> V2CallResult { + func v2MobileChatSessions(params: [String: Any]) async -> V2CallResult { let workspaceID = v2String(params, "workspace_id") guard let service = agentChatTranscriptService else { return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) } - // Observe-floor detection: kick a throttled, off-main process-table scan - // so a live codex/claude launched through any indirection (a subrouter, a - // wrapper) that fired no hook is still discovered. Fire-and-forget: a new - // detection creates a record that pushes itself to subscribers via - // onRecordChanged, so it lands without blocking this list pull. - Task { await service.observeAgentProcesses() } guard let workspaceID else { - // No filter: return all current-agent sessions across workspaces, - // resolving each via its stored binding as before. + let observedBeforeListing = await service.observeAgentProcessesForListing( + surfaceIDs: nil, + waitUpTo: .milliseconds(750) + ) + #if DEBUG + if !observedBeforeListing { + cmuxDebugLog("agentChat.list observeTimedOut workspace=nil") + } + #endif let descriptors = service.sessionRecords(workspaceID: nil) .filter { mobileChatBindingIsCurrentAgent($0) } .map(\.descriptor) @@ -108,9 +109,20 @@ extension TerminalController { return .ok(["sessions": []]) } let workspace = resolved.workspace + let terminalSurfaceIDs = Set(workspace.panels.compactMap { panelID, panel in panel is TerminalPanel ? panelID : nil }) + // Workspace GUI pulls force a scoped scan and wait only to a local deadline. + let observedBeforeListing = await service.observeAgentProcessesForListing( + surfaceIDs: terminalSurfaceIDs, + waitUpTo: .milliseconds(750) + ) + #if DEBUG + if !observedBeforeListing { + cmuxDebugLog("agentChat.list observeTimedOut workspace=\(workspaceID.prefix(8))") + } + #endif var encoded: [[String: Any]] = [] #if DEBUG - var dropNotInWorkspace = 0, dropDeadPID = 0, kept = 0 + var dropNotInWorkspace = 0, dropDeadPID = 0, dropEndedMissingTranscript = 0, kept = 0 let allRecords = service.sessionRecords(workspaceID: nil) #endif for record in service.sessionRecords(workspaceID: nil) { @@ -122,13 +134,8 @@ extension TerminalController { #endif continue } - // A LIVE session must still be the current agent on the terminal, so - // a reused/restored terminal never exposes a false live toggle. An - // ENDED session is RETAINED whenever its surface is a live terminal - // in W, regardless of what the terminal runs now: the GUI keeps a - // finished conversation visible read-only (input bar disabled), so a - // fresh pull must not drop it — dropping it is what made the toggle - // go stale and vanish on tap after the agent exited. + // Live sessions must match the terminal's current agent. Ended + // sessions stay visible read-only while their surface is still in W. if record.state != .ended, !mobileChatRecordMatchesAgent(record: record) { #if DEBUG @@ -137,12 +144,18 @@ extension TerminalController { #endif continue } + if record.state == .ended, + !service.shouldListEndedSession(record) { + #if DEBUG + dropEndedMissingTranscript += 1 + cmuxDebugLog("agentChat.list drop=endedMissingTranscript session=\(record.sessionID.prefix(8)) kind=\(record.agentKind.sourceName) surface=\(record.surfaceID?.prefix(8) ?? "nil")") + #endif + continue + } #if DEBUG kept += 1 #endif - // Re-stamp stale-workspace records to W so the seed and live pushes - // both scope to the current workspace, then encode the re-stamped - // descriptor. + // Re-stamp stale-workspace records so seeds and pushes scope to W. if record.workspaceID != workspaceID { service.updateSessionWorkspace(sessionID: record.sessionID, workspaceID: workspaceID) } @@ -152,7 +165,7 @@ extension TerminalController { } } #if DEBUG - cmuxDebugLog("agentChat.list workspace=\(workspaceID.prefix(8)) total=\(allRecords.count) dropNotInWS=\(dropNotInWorkspace) dropDeadPID=\(dropDeadPID) kept=\(kept) returned=\(encoded.count)") + cmuxDebugLog("agentChat.list workspace=\(workspaceID.prefix(8)) total=\(allRecords.count) dropNotInWS=\(dropNotInWorkspace) dropDeadPID=\(dropDeadPID) dropEndedMissingTranscript=\(dropEndedMissingTranscript) kept=\(kept) returned=\(encoded.count)") #endif return .ok(["sessions": encoded]) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1e0b1e4f6525..ad4a968f0792 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -9,12 +9,26 @@ /* Begin PBXBuildFile section */ A9E020000000000000000006 /* agent-session-react in Resources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000006 /* agent-session-react */; }; A9E020000000000000000007 /* agent-session-solid in Resources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000007 /* agent-session-solid */; }; + C7A52E000000000000000002 /* AgentChatEndedTranscriptListabilityCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52E000000000000000001 /* AgentChatEndedTranscriptListabilityCache.swift */; }; ACA7C4A70000000000000003 /* AgentChatHookSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000004 /* AgentChatHookSessionStore.swift */; }; + C7A531000000000000000002 /* AgentChatObservationHandle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A531000000000000000001 /* AgentChatObservationHandle.swift */; }; + C7A532000000000000000002 /* AgentChatObservationInFlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A532000000000000000001 /* AgentChatObservationInFlight.swift */; }; + C7A530000000000000000002 /* AgentChatObservationScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A530000000000000000001 /* AgentChatObservationScope.swift */; }; CDFE000000000000000000A1 /* AgentChatProseStreamer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDFE000000000000000000A2 /* AgentChatProseStreamer.swift */; }; CDFE000000000000000000C1 /* AgentChatProseStreamerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDFE000000000000000000C2 /* AgentChatProseStreamerTests.swift */; }; ACA7C4A70000000000000001 /* AgentChatSessionRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000002 /* AgentChatSessionRecord.swift */; }; + C7A52D100000000000000002 /* AgentChatSessionRegistry+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52D100000000000000001 /* AgentChatSessionRegistry+Lifecycle.swift */; }; + C7A52D200000000000000002 /* AgentChatSessionRegistry+LiveAgentPID.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52D200000000000000001 /* AgentChatSessionRegistry+LiveAgentPID.swift */; }; + C7A52D000000000000000002 /* AgentChatSessionRegistry+ObserveScan.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52D000000000000000001 /* AgentChatSessionRegistry+ObserveScan.swift */; }; ACA7C4A70000000000000007 /* AgentChatSessionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */; }; + C7A51F000000000000000002 /* AgentChatSessionRegistryClaudeObservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51F000000000000000001 /* AgentChatSessionRegistryClaudeObservationTests.swift */; }; + C7A51C000000000000000002 /* AgentChatSessionRegistryHookStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51C000000000000000001 /* AgentChatSessionRegistryHookStoreTests.swift */; }; + C7A51E100000000000000002 /* AgentChatSessionRegistryLifecycleReviewRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51E100000000000000001 /* AgentChatSessionRegistryLifecycleReviewRegressionTests.swift */; }; + C7A51E000000000000000002 /* AgentChatSessionRegistryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51E000000000000000001 /* AgentChatSessionRegistryLifecycleTests.swift */; }; + C7A51D100000000000000002 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51D100000000000000001 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift */; }; + C7A51D000000000000000002 /* AgentChatSessionRegistryObservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51D000000000000000001 /* AgentChatSessionRegistryObservationTests.swift */; }; ACA7C4A70000000000000005 /* AgentChatTranscriptResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */; }; + C7A52F000000000000000002 /* AgentChatTranscriptService+Wire.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52F000000000000000001 /* AgentChatTranscriptService+Wire.swift */; }; ACA7C4A7000000000000000B /* AgentChatTranscriptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */; }; ACA7C4A70000000000000009 /* AgentChatTranscriptTailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */; }; A9F200000000000000000001 /* AgentExecutableResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000001 /* AgentExecutableResolver.swift */; }; @@ -225,8 +239,10 @@ D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; }; A5D41232A1B2C3D4E5F60718 /* ClaudeBackgroundWorkNotifyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41233A1B2C3D4E5F60718 /* ClaudeBackgroundWorkNotifyTests.swift */; }; C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */; }; + C7A534000000000000000002 /* ClaudeHookFeedTelemetrySwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A534000000000000000001 /* ClaudeHookFeedTelemetrySwiftTests.swift */; }; A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */; }; A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */; }; + C7A533000000000000000002 /* ClaudeSessionCanonicalizationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A533000000000000000001 /* ClaudeSessionCanonicalizationContext.swift */; }; A9F200000000000000000015 /* ClaudeStreamJSONAccumulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */; }; CE7000000000000000000001 /* ClaudeWrapperResumeEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7000000000000000000002 /* ClaudeWrapperResumeEnvironmentTests.swift */; }; A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */; }; @@ -1346,12 +1362,26 @@ /* Begin PBXFileReference section */ A9E010000000000000000006 /* agent-session-react */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "agent-session-react"; sourceTree = ""; }; A9E010000000000000000007 /* agent-session-solid */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "agent-session-solid"; sourceTree = ""; }; + C7A52E000000000000000001 /* AgentChatEndedTranscriptListabilityCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatEndedTranscriptListabilityCache.swift"; sourceTree = ""; }; ACA7C4A70000000000000004 /* AgentChatHookSessionStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatHookSessionStore.swift"; sourceTree = ""; }; + C7A531000000000000000001 /* AgentChatObservationHandle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatObservationHandle.swift"; sourceTree = ""; }; + C7A532000000000000000001 /* AgentChatObservationInFlight.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatObservationInFlight.swift"; sourceTree = ""; }; + C7A530000000000000000001 /* AgentChatObservationScope.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatObservationScope.swift"; sourceTree = ""; }; CDFE000000000000000000A2 /* AgentChatProseStreamer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatProseStreamer.swift"; sourceTree = ""; }; CDFE000000000000000000C2 /* AgentChatProseStreamerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatProseStreamerTests.swift; sourceTree = ""; }; ACA7C4A70000000000000002 /* AgentChatSessionRecord.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRecord.swift"; sourceTree = ""; }; + C7A52D100000000000000001 /* AgentChatSessionRegistry+Lifecycle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry+Lifecycle.swift"; sourceTree = ""; }; + C7A52D200000000000000001 /* AgentChatSessionRegistry+LiveAgentPID.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry+LiveAgentPID.swift"; sourceTree = ""; }; + C7A52D000000000000000001 /* AgentChatSessionRegistry+ObserveScan.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry+ObserveScan.swift"; sourceTree = ""; }; ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry.swift"; sourceTree = ""; }; + C7A51F000000000000000001 /* AgentChatSessionRegistryClaudeObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryClaudeObservationTests.swift; sourceTree = ""; }; + C7A51C000000000000000001 /* AgentChatSessionRegistryHookStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryHookStoreTests.swift; sourceTree = ""; }; + C7A51E100000000000000001 /* AgentChatSessionRegistryLifecycleReviewRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryLifecycleReviewRegressionTests.swift; sourceTree = ""; }; + C7A51E000000000000000001 /* AgentChatSessionRegistryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryLifecycleTests.swift; sourceTree = ""; }; + C7A51D100000000000000001 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryObservationReviewRegressionTests.swift; sourceTree = ""; }; + C7A51D000000000000000001 /* AgentChatSessionRegistryObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryObservationTests.swift; sourceTree = ""; }; ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptResolver.swift"; sourceTree = ""; }; + C7A52F000000000000000001 /* AgentChatTranscriptService+Wire.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService+Wire.swift"; sourceTree = ""; }; ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService.swift"; sourceTree = ""; }; ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptTailer.swift"; sourceTree = ""; }; A9F100000000000000000001 /* AgentExecutableResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentExecutableResolver.swift; sourceTree = ""; }; @@ -1560,8 +1590,10 @@ D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEMarkedSelectionTests.swift; sourceTree = ""; }; A5D41233A1B2C3D4E5F60718 /* ClaudeBackgroundWorkNotifyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeBackgroundWorkNotifyTests.swift; sourceTree = ""; }; C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeConfigDirectoryPathTests.swift; sourceTree = ""; }; + C7A534000000000000000001 /* ClaudeHookFeedTelemetrySwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookFeedTelemetrySwiftTests.swift; sourceTree = ""; }; A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookSurfaceResolutionSwiftTests.swift; sourceTree = ""; }; A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNotificationStatusLifecycleTests.swift; sourceTree = ""; }; + C7A533000000000000000001 /* ClaudeSessionCanonicalizationContext.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "ClaudeSessionCanonicalizationContext.swift"; sourceTree = ""; }; A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ClaudeStreamJSONAccumulator.swift; sourceTree = ""; }; CE7000000000000000000002 /* ClaudeWrapperResumeEnvironmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeWrapperResumeEnvironmentTests.swift; sourceTree = ""; }; A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIAuthAliasTests.swift; sourceTree = ""; }; @@ -2727,10 +2759,19 @@ children = ( ACA7C4A70000000000000002 /* AgentChatSessionRecord.swift */, ACA7C4A70000000000000004 /* AgentChatHookSessionStore.swift */, + C7A531000000000000000001 /* AgentChatObservationHandle.swift */, + C7A532000000000000000001 /* AgentChatObservationInFlight.swift */, + C7A530000000000000000001 /* AgentChatObservationScope.swift */, ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */, - ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */, - ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */, + ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */, + C7A52D100000000000000001 /* AgentChatSessionRegistry+Lifecycle.swift */, + C7A52D200000000000000001 /* AgentChatSessionRegistry+LiveAgentPID.swift */, + C7A52D000000000000000001 /* AgentChatSessionRegistry+ObserveScan.swift */, + C7A52E000000000000000001 /* AgentChatEndedTranscriptListabilityCache.swift */, + ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */, ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */, + C7A52F000000000000000001 /* AgentChatTranscriptService+Wire.swift */, + C7A533000000000000000001 /* ClaudeSessionCanonicalizationContext.swift */, CDFE000000000000000000A2 /* AgentChatProseStreamer.swift */, ); name = AgentChat; @@ -3639,6 +3680,12 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */, CDFE000000000000000000C2 /* AgentChatProseStreamerTests.swift */, + C7A51F000000000000000001 /* AgentChatSessionRegistryClaudeObservationTests.swift */, + C7A51C000000000000000001 /* AgentChatSessionRegistryHookStoreTests.swift */, + C7A51E100000000000000001 /* AgentChatSessionRegistryLifecycleReviewRegressionTests.swift */, + C7A51E000000000000000001 /* AgentChatSessionRegistryLifecycleTests.swift */, + C7A51D100000000000000001 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift */, + C7A51D000000000000000001 /* AgentChatSessionRegistryObservationTests.swift */, D3610B010000000000000002 /* AgentSessionAutoResumeSettingsTests.swift */, D3610B020000000000000002 /* AgentSessionAutoResumeSwiftTests.swift */, A9E010000000000000000005 /* AgentExecutableResolverTests.swift */, @@ -3891,6 +3938,7 @@ A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */, A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */, A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */, + C7A534000000000000000001 /* ClaudeHookFeedTelemetrySwiftTests.swift */, A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */, A5D41231A1B2C3D4E5F60718 /* AgentNotificationGateTests.swift */, A5D41233A1B2C3D4E5F60718 /* ClaudeBackgroundWorkNotifyTests.swift */, @@ -4360,11 +4408,19 @@ buildActionMask = 2147483647; files = ( + C7A52E000000000000000002 /* AgentChatEndedTranscriptListabilityCache.swift in Sources */, ACA7C4A70000000000000003 /* AgentChatHookSessionStore.swift in Sources */, + C7A531000000000000000002 /* AgentChatObservationHandle.swift in Sources */, + C7A532000000000000000002 /* AgentChatObservationInFlight.swift in Sources */, + C7A530000000000000000002 /* AgentChatObservationScope.swift in Sources */, CDFE000000000000000000A1 /* AgentChatProseStreamer.swift in Sources */, - ACA7C4A70000000000000001 /* AgentChatSessionRecord.swift in Sources */, - ACA7C4A70000000000000007 /* AgentChatSessionRegistry.swift in Sources */, + ACA7C4A70000000000000001 /* AgentChatSessionRecord.swift in Sources */, + C7A52D100000000000000002 /* AgentChatSessionRegistry+Lifecycle.swift in Sources */, + C7A52D200000000000000002 /* AgentChatSessionRegistry+LiveAgentPID.swift in Sources */, + C7A52D000000000000000002 /* AgentChatSessionRegistry+ObserveScan.swift in Sources */, + ACA7C4A70000000000000007 /* AgentChatSessionRegistry.swift in Sources */, ACA7C4A70000000000000005 /* AgentChatTranscriptResolver.swift in Sources */, + C7A52F000000000000000002 /* AgentChatTranscriptService+Wire.swift in Sources */, ACA7C4A7000000000000000B /* AgentChatTranscriptService.swift in Sources */, ACA7C4A70000000000000009 /* AgentChatTranscriptTailer.swift in Sources */, A9F200000000000000000001 /* AgentExecutableResolver.swift in Sources */, @@ -4512,6 +4568,7 @@ CA52E0030000000000000000 /* CanvasInlineBrowserHostingEnvironment.swift in Sources */, CA52B0060000000000000000 /* CanvasLayoutSettings.swift in Sources */, CA52B0070000000000000000 /* CanvasPaneContent.swift in Sources */, + C7A533000000000000000002 /* ClaudeSessionCanonicalizationContext.swift in Sources */, A9F200000000000000000015 /* ClaudeStreamJSONAccumulator.swift in Sources */, C46790000000000000000003 /* CLIForwardingLaunchRouter.swift in Sources */, C10D51700000000000000002 /* ClosedItemHistory.swift in Sources */, @@ -5243,6 +5300,12 @@ buildActionMask = 2147483647; files = ( CDFE000000000000000000C1 /* AgentChatProseStreamerTests.swift in Sources */, + C7A51F000000000000000002 /* AgentChatSessionRegistryClaudeObservationTests.swift in Sources */, + C7A51C000000000000000002 /* AgentChatSessionRegistryHookStoreTests.swift in Sources */, + C7A51E100000000000000002 /* AgentChatSessionRegistryLifecycleReviewRegressionTests.swift in Sources */, + C7A51E000000000000000002 /* AgentChatSessionRegistryLifecycleTests.swift in Sources */, + C7A51D100000000000000002 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift in Sources */, + C7A51D000000000000000002 /* AgentChatSessionRegistryObservationTests.swift in Sources */, A9E020000000000000000005 /* AgentExecutableResolverTests.swift in Sources */, D36A00020000000000000001 /* AgentHibernationTests.swift in Sources */, A5D41230A1B2C3D4E5F60718 /* AgentNotificationGateTests.swift in Sources */, @@ -5294,6 +5357,7 @@ D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */, A5D41232A1B2C3D4E5F60718 /* ClaudeBackgroundWorkNotifyTests.swift in Sources */, C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */, + C7A534000000000000000002 /* ClaudeHookFeedTelemetrySwiftTests.swift in Sources */, A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */, A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */, CE7000000000000000000001 /* ClaudeWrapperResumeEnvironmentTests.swift in Sources */, diff --git a/cmuxTests/AgentChatSessionRegistryClaudeObservationTests.swift b/cmuxTests/AgentChatSessionRegistryClaudeObservationTests.swift new file mode 100644 index 000000000000..4ce646acacf6 --- /dev/null +++ b/cmuxTests/AgentChatSessionRegistryClaudeObservationTests.swift @@ -0,0 +1,479 @@ +import Foundation +import Testing +import CMUXAgentLaunch + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +struct AgentChatSessionRegistryClaudeObservationTests { + @Test func mobileChatObserverDetectsBunHostedClaudeFromProcessDetails() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "b6fbc8e1-2c4b-4e51-a2b8-fd17c2ad59f0" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 102, + name: "bun", + path: "/Users/example/.bun/bin/bun", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 102), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 102 else { return nil } + return CmuxTopProcessArguments( + arguments: [ + "bun", + "/Users/example/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js", + ], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": sessionID, + "CMUX_AGENT_LAUNCH_CWD": "/Users/example/bun-project", + ] + ) + }, + codexRolloutPath: { _ in nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == sessionID) + #expect(session.agentKind == .claude) + #expect(session.workspaceID == workspaceID.uuidString) + #expect(session.surfaceID == surfaceID.uuidString) + #expect(session.pid == 102) + #expect(session.workingDirectory == "/Users/example/bun-project") + } + + @Test func mobileChatObserverDetectsVersionNumberClaudeLauncherFromPath() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "5a2df315-4e1a-401f-9a46-b0601872bd5d" + let launcherPath = "/Users/example/.local/share/claude/versions/2.1.140" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 103, + name: "2.1.140", + path: launcherPath, + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 103), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 103 else { return nil } + return CmuxTopProcessArguments( + arguments: [ + launcherPath, + "--resume", + sessionID, + ], + environment: [ + "PWD": "/Users/example/versioned-project", + ] + ) + }, + codexRolloutPath: { _ in nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == sessionID) + #expect(session.agentKind == .claude) + #expect(session.workspaceID == workspaceID.uuidString) + #expect(session.surfaceID == surfaceID.uuidString) + #expect(session.pid == 103) + #expect(session.workingDirectory == "/Users/example/versioned-project") + } + + @Test func mobileChatObserverDetectsClaudeShortResumeFlag() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 104, + name: "claude", + path: "/opt/homebrew/bin/claude", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 104), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 104 else { return nil } + return CmuxTopProcessArguments( + arguments: ["claude", "-r", sessionID], + environment: [ + "CMUX_AGENT_LAUNCH_CWD": "/Users/example/short-resume", + ] + ) + }, + codexRolloutPath: { _ in nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == sessionID) + #expect(session.agentKind == .claude) + } + + @Test func mobileChatObserverRejectsOptionLikeClaudeResumeValues() { + let workspaceID = UUID() + let surfaceID = UUID() + let optionUUID = "b6fbc8e1-2c4b-4e51-a2b8-fd17c2ad59f0" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 105, + name: "claude", + path: "/opt/homebrew/bin/claude", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 105), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 105 else { return nil } + return CmuxTopProcessArguments( + arguments: ["claude", "--resume", "--flag=\(optionUUID)"], + environment: [:] + ) + }, + codexRolloutPath: { _ in nil } + ) + + #expect(observed.isEmpty) + } + + @Test func mobileChatObserverScopedScanIgnoresOtherSurfacesWithoutReadingDetails() throws { + let workspaceID = UUID() + let includedSurfaceID = UUID() + let excludedSurfaceID = UUID() + let includedSessionID = "1f55cb96-0741-41f8-bd3b-8b0cd18ae047" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 120, + name: "bun", + path: "/Users/example/.bun/bin/bun", + workspaceID: workspaceID, + surfaceID: includedSurfaceID + ), + topProcess( + pid: 121, + name: "bun", + path: "/Users/example/.bun/bin/bun", + workspaceID: workspaceID, + surfaceID: excludedSurfaceID + ), + ], + sampledAt: Date(timeIntervalSince1970: 120), + includesProcessDetails: true + ) + var requestedDetailPIDs: [Int] = [] + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + onlySurfaceIDs: [includedSurfaceID], + processArgumentsAndEnvironment: { pid in + requestedDetailPIDs.append(pid) + guard pid == 120 else { return nil } + return CmuxTopProcessArguments( + arguments: [ + "bun", + "/Users/example/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js", + ], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": includedSessionID, + "CMUX_AGENT_LAUNCH_CWD": "/Users/example/scoped-project", + ] + ) + }, + codexRolloutPath: { _ in nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == includedSessionID) + #expect(session.surfaceID == includedSurfaceID.uuidString) + #expect(requestedDetailPIDs == [120]) + } + + @Test func mobileChatObserverIgnoresBackgroundClaudeProcess() { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 707, + name: "claude", + path: "/opt/homebrew/bin/claude", + workspaceID: workspaceID, + surfaceID: surfaceID, + isForeground: false + ) + ], + sampledAt: Date(timeIntervalSince1970: 700), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 707 else { return nil } + return CmuxTopProcessArguments( + arguments: ["claude"], + environment: [ + "CLAUDE_CODE_SESSION_ID": sessionID, + "CMUX_AGENT_LAUNCH_CWD": "/Users/example/project", + ] + ) + }, + codexRolloutPath: { _ in nil } + ) + + #expect(observed.isEmpty) + } + + @Test func mobileChatLivenessIgnoresBackgroundClaudeProcess() { + let workspaceID = UUID() + let surfaceID = UUID() + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 808, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID, + isForeground: false + ) + ], + sampledAt: Date(timeIntervalSince1970: 800), + includesProcessDetails: true + ) + + let livePID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + processArgumentsAndEnvironment: { pid in + guard pid == 808 else { return nil } + return CmuxTopProcessArguments( + arguments: [ + "node", + "/Users/example/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js", + ], + environment: [ + "CLAUDE_CODE_SESSION_ID": "24ec0052-450c-4914-b1dd-2ee80d4bc84b", + ] + ) + } + ) + + #expect(livePID == nil) + } + + @Test func mobileChatLivenessPrefersDeepestMatchingClaudeSessionIdentity() { + let workspaceID = UUID() + let surfaceID = UUID() + let expectedSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 809, + name: "claude", + path: "/opt/homebrew/bin/claude", + workspaceID: workspaceID, + surfaceID: surfaceID + ), + topProcess( + pid: 810, + name: "claude", + path: "/opt/homebrew/bin/claude", + workspaceID: workspaceID, + surfaceID: surfaceID, + parentPID: 809 + ), + ], + sampledAt: Date(timeIntervalSince1970: 810), + includesProcessDetails: true + ) + + let livePID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + matchingSessionIDs: [expectedSessionID], + processArgumentsAndEnvironment: { pid in + return CmuxTopProcessArguments( + arguments: ["claude"], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": expectedSessionID, + ] + ) + } + ) + + #expect(livePID == 810) + } + + @Test func observationScopeOnlyReusesInFlightScansThatCoverRequestedSurfaces() { + let surfaceA = UUID() + let surfaceB = UUID() + let all = AgentChatObservationScope(surfaceIDs: nil) + let scanA = AgentChatObservationScope(surfaceIDs: [surfaceA]) + let scanAB = AgentChatObservationScope(surfaceIDs: [surfaceA, surfaceB]) + let requestA = AgentChatObservationScope(surfaceIDs: [surfaceA]) + let requestB = AgentChatObservationScope(surfaceIDs: [surfaceB]) + + #expect(all.covers(requestA)) + #expect(scanAB.covers(requestA)) + #expect(scanAB.covers(requestB)) + #expect(scanA.covers(requestA)) + #expect(!scanA.covers(requestB)) + #expect(!scanA.covers(all)) + #expect(!requestA.covers(scanAB)) + } + + @MainActor + @Test func observationWaitTimeoutRemovesWaiterWithoutDrainingSlowTask() async { + let clock = ContinuousClock() + let slowTask = Task { + do { + try await Task.sleep(for: .seconds(5)) + } catch {} + } + defer { slowTask.cancel() } + let registry = AgentChatSessionRegistry() + let observationID = UUID() + registry.observeInFlight = AgentChatObservationInFlight( + id: observationID, + scope: .all, + task: slowTask + ) + let handle = AgentChatObservationHandle(id: observationID, task: slowTask) + + let start = clock.now + let completed = await registry.waitForObservation( + handle, + upTo: .milliseconds(50) + ) + let elapsed = start.duration(to: clock.now) + + #expect(!completed) + #expect(elapsed < .seconds(1)) + #expect(registry.observeInFlight?.waiters.isEmpty == true) + } + + @MainActor + @Test func replacingObservationResumesPreviousWaitersAsStale() async { + let surfaceA = UUID() + let surfaceB = UUID() + let oldTask = Task { + do { + try await Task.sleep(for: .seconds(5)) + } catch {} + } + let newTask = Task { + do { + try await Task.sleep(for: .seconds(5)) + } catch {} + } + defer { + oldTask.cancel() + newTask.cancel() + } + let registry = AgentChatSessionRegistry() + let oldID = UUID() + registry.observeInFlight = AgentChatObservationInFlight( + id: oldID, + scope: AgentChatObservationScope(surfaceIDs: [surfaceA]), + task: oldTask + ) + let waiter = Task { @MainActor in + await registry.waitForObservation( + AgentChatObservationHandle(id: oldID, task: oldTask), + upTo: .seconds(5) + ) + } + for _ in 0..<10 where registry.observeInFlight?.waiters.isEmpty == true { + await Task.yield() + } + #expect(registry.observeInFlight?.waiters.count == 1) + + registry.replaceAgentProcessObservation( + with: AgentChatObservationInFlight( + id: UUID(), + scope: AgentChatObservationScope(surfaceIDs: [surfaceB]), + task: newTask + ) + ) + + let completed = await waiter.value + #expect(!completed) + #expect(oldTask.isCancelled) + #expect(registry.observeInFlight?.scope == AgentChatObservationScope(surfaceIDs: [surfaceB])) + #expect(registry.observeInFlight?.waiters.isEmpty == true) + } + + private func topProcess( + pid: Int, + name: String, + path: String?, + workspaceID: UUID, + surfaceID: UUID, + isForeground: Bool = true, + parentPID: Int = 1 + ) -> CmuxTopProcessInfo { + let processGroupID = pid + return CmuxTopProcessInfo( + pid: pid, + parentPID: parentPID, + name: name, + path: path, + ttyDevice: nil, + cmuxWorkspaceID: workspaceID, + cmuxSurfaceID: surfaceID, + cmuxAttributionReason: "test", + processGroupID: processGroupID, + terminalProcessGroupID: isForeground ? processGroupID : processGroupID + 1, + cpuPercent: 0, + residentBytes: 1, + virtualBytes: 1, + threadCount: 1 + ) + } +} diff --git a/cmuxTests/AgentChatSessionRegistryHookStoreTests.swift b/cmuxTests/AgentChatSessionRegistryHookStoreTests.swift new file mode 100644 index 000000000000..e356bf229471 --- /dev/null +++ b/cmuxTests/AgentChatSessionRegistryHookStoreTests.swift @@ -0,0 +1,285 @@ +import Foundation +import Testing +import Darwin +import CMUXAgentLaunch + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +struct AgentChatSessionRegistryHookStoreTests { + @Test func mobileChatObserverDetectsCmuxLaunchedOpaqueClaudeWrapper() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 121, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 115), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 121 else { return nil } + return CmuxTopProcessArguments( + arguments: [ + "node", + "/Users/example/.cmux-agent-wrapper/subrouter.js", + ], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": sessionID, + "CMUX_AGENT_LAUNCH_CWD": "/Users/example/opaque-project", + ] + ) + }, + codexRolloutPath: { _ in nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == sessionID) + #expect(session.agentKind == .claude) + #expect(session.workspaceID == workspaceID.uuidString) + #expect(session.surfaceID == surfaceID.uuidString) + #expect(session.pid == 121) + #expect(session.workingDirectory == "/Users/example/opaque-project") + } + + @Test func unidentifiedClaudeLivenessFallbackOnlyAppliesToUnresolvedPendingAlias() { + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let now = Date(timeIntervalSince1970: 120) + var pending = AgentChatSessionRecord( + sessionID: pendingID, + agentKind: .claude, + workspaceID: UUID().uuidString, + surfaceID: surfaceID, + workingDirectory: nil, + transcriptPath: nil, + state: .idle, + lastActivityAt: now, + title: nil, + pid: nil + ) + + #expect(AgentChatSessionRegistry.allowsUnidentifiedClaudeLivenessFallback(for: pending)) + + pending.rememberHookStoreSessionID(realSessionID) + #expect(!AgentChatSessionRegistry.allowsUnidentifiedClaudeLivenessFallback(for: pending)) + + let real = AgentChatSessionRecord( + sessionID: realSessionID, + agentKind: .claude, + workspaceID: UUID().uuidString, + surfaceID: surfaceID, + workingDirectory: nil, + transcriptPath: nil, + state: .idle, + lastActivityAt: now, + title: nil, + pid: nil + ) + #expect(!AgentChatSessionRegistry.allowsUnidentifiedClaudeLivenessFallback(for: real)) + } + + @Test func mobileChatObserverRejectsArgvOnlyClaudeNeedleWithoutLaunchKind() { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 122, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 116), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 122 else { return nil } + return CmuxTopProcessArguments( + arguments: [ + "node", + "/Users/example/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js", + ], + environment: [ + "CLAUDE_CODE_SESSION_ID": sessionID, + "CMUX_AGENT_LAUNCH_CWD": "/Users/example/not-authoritative", + ] + ) + }, + codexRolloutPath: { _ in nil } + ) + + #expect(observed.isEmpty) + } + + @MainActor + @Test func hookStoreSeedKeepsStaleRealEntrySeparateFromPendingClaudeSession() async throws { + let home = try temporaryHomeDirectory() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let transcriptPath = "/Users/example/.claude/projects/-Users-example-project/\(realSessionID).jsonl" + let stalePID = try #require(guaranteedDeadPID()) + try writeClaudeHookStore( + home: home, + sessionID: realSessionID, + workspaceID: workspaceID, + surfaceID: surfaceID, + transcriptPath: transcriptPath, + pid: stalePID + ) + let registry = AgentChatSessionRegistry( + hookStore: AgentChatHookSessionStore(homeDirectory: home) + ) + + registry.noteResumeInitiated( + sessionID: pendingID, + source: "claude", + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: "/Users/example/project" + ) + await registry.seedFromHookStores(agentSources: ["claude"]) + + let pending = try #require(registry.record(sessionID: pendingID)) + let historical = try #require(registry.record(sessionID: realSessionID)) + #expect(pending.transcriptPath == nil) + #expect(pending.hookStoreSessionID == nil) + #expect(historical.transcriptPath == transcriptPath) + #expect(historical.state == .ended) + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == pendingID) + } + + @MainActor + @Test func hookStoreSeedMergesPidMatchedRealEntryIntoPendingClaudeSession() async throws { + let home = try temporaryHomeDirectory() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let transcriptPath = "/Users/example/.claude/projects/-Users-example-project/\(realSessionID).jsonl" + let livePID = Int(ProcessInfo.processInfo.processIdentifier) + try writeClaudeHookStore( + home: home, + sessionID: realSessionID, + workspaceID: workspaceID, + surfaceID: surfaceID, + transcriptPath: transcriptPath, + pid: livePID + ) + let registry = AgentChatSessionRegistry( + hookStore: AgentChatHookSessionStore(homeDirectory: home) + ) + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: livePID, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + await registry.seedFromHookStores(agentSources: ["claude"]) + + let record = try #require(registry.record(sessionID: pendingID)) + #expect(registry.record(sessionID: realSessionID) == nil) + #expect(record.hookStoreSessionID == realSessionID) + #expect(record.transcriptPath == transcriptPath) + #expect(record.pid == livePID) + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == pendingID) + } + + private func guaranteedDeadPID() -> Int? { + for pid in 900_000..<1_000_000 { + errno = 0 + if kill(pid_t(pid), 0) != 0, errno == ESRCH { + return pid + } + } + return nil + } + + private func topProcess( + pid: Int, + name: String, + path: String?, + workspaceID: UUID, + surfaceID: UUID + ) -> CmuxTopProcessInfo { + CmuxTopProcessInfo( + pid: pid, + parentPID: 1, + name: name, + path: path, + ttyDevice: nil, + cmuxWorkspaceID: workspaceID, + cmuxSurfaceID: surfaceID, + cmuxAttributionReason: "test", + processGroupID: pid, + terminalProcessGroupID: pid, + cpuPercent: 0, + residentBytes: 1, + virtualBytes: 1, + threadCount: 1 + ) + } + + private func temporaryHomeDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-chat-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func writeClaudeHookStore( + home: URL, + sessionID: String, + workspaceID: String, + surfaceID: String, + transcriptPath: String, + pid: Int + ) throws { + let directory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let payload: [String: Any] = [ + "sessions": [ + sessionID: [ + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "cwd": "/Users/example/project", + "transcriptPath": transcriptPath, + "pid": pid, + "updatedAt": 140.0, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + try data.write(to: directory.appendingPathComponent("claude-hook-sessions.json")) + } +} diff --git a/cmuxTests/AgentChatSessionRegistryLifecycleReviewRegressionTests.swift b/cmuxTests/AgentChatSessionRegistryLifecycleReviewRegressionTests.swift new file mode 100644 index 000000000000..dc53735dc4de --- /dev/null +++ b/cmuxTests/AgentChatSessionRegistryLifecycleReviewRegressionTests.swift @@ -0,0 +1,265 @@ +import CMUXAgentLaunch +import CmuxAgentChat +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +struct AgentChatSessionRegistryLifecycleReviewRegressionTests { + @MainActor + @Test func endedSessionListabilityRetriesTransientMissingTranscriptAfterRetryWindow() throws { + let home = try temporaryHomeDirectory() + var now = Date(timeIntervalSince1970: 260) + let service = AgentChatTranscriptService( + registry: AgentChatSessionRegistry(), + resolver: AgentChatTranscriptResolver(homeDirectory: home, environment: [:]), + now: { now } + ) + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let transcriptURL = home + .appendingPathComponent(".claude/projects/-Users-example-project", isDirectory: true) + .appendingPathComponent("\(sessionID).jsonl") + + service.noteHookEvent(WorkstreamEvent( + sessionId: sessionID, + hookEventName: .sessionEnd, + source: "claude", + workspaceId: workspaceID, + surfaceId: surfaceID, + transcriptPath: transcriptURL.path, + cwd: "/Users/example/project", + ppid: nil, + receivedAt: Date(timeIntervalSince1970: 260) + )) + let initiallyMissingRecord = try #require(service.sessionRecord(sessionID: sessionID)) + #expect(!service.shouldListEndedSession(initiallyMissingRecord)) + + try FileManager.default.createDirectory( + at: transcriptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "{}\n".write(to: transcriptURL, atomically: true, encoding: .utf8) + + let resolvedRecord = try #require(service.sessionRecord(sessionID: sessionID)) + now = Date(timeIntervalSince1970: 264) + #expect(!service.shouldListEndedSession(resolvedRecord)) + now = Date(timeIntervalSince1970: 266) + #expect(service.shouldListEndedSession(resolvedRecord)) + } + + @Test func endedListabilityCacheRefreshesExpiredMissingTranscript() throws { + let home = try temporaryHomeDirectory() + let resolver = AgentChatTranscriptResolver(homeDirectory: home, environment: [:]) + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let transcriptURL = home + .appendingPathComponent(".claude/projects/-Users-example-project", isDirectory: true) + .appendingPathComponent("\(sessionID).jsonl") + let record = AgentChatSessionRecord( + sessionID: sessionID, + agentKind: .claude, + workspaceID: UUID().uuidString, + surfaceID: UUID().uuidString, + workingDirectory: "/Users/example/project", + transcriptPath: transcriptURL.path, + state: .ended, + endedAt: Date(timeIntervalSince1970: 10), + lastActivityAt: Date(timeIntervalSince1970: 10), + title: nil, + pid: nil, + hookStoreSessionID: nil + ) + var cache = AgentChatEndedTranscriptListabilityCache() + + let initiallyListable = cache.shouldList( + record, + resolver: resolver, + now: Date(timeIntervalSince1970: 10) + ) + #expect(!initiallyListable) + + try FileManager.default.createDirectory( + at: transcriptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "{}\n".write(to: transcriptURL, atomically: true, encoding: .utf8) + + let beforeRetryWindowListable = cache.shouldList( + record, + resolver: resolver, + now: Date(timeIntervalSince1970: 14) + ) + #expect(!beforeRetryWindowListable) + + let eventuallyListable = cache.shouldList( + record, + resolver: resolver, + now: Date(timeIntervalSince1970: 16) + ) + #expect(eventuallyListable) + } + + @MainActor + @Test func observeScanDoesNotReviveEndedRecordForSamePID() throws { + let registry = AgentChatSessionRegistry() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + registry.noteHookEvent(WorkstreamEvent( + sessionId: sessionID, + hookEventName: .sessionStart, + source: "claude", + workspaceId: workspaceID, + surfaceId: surfaceID, + ppid: 303, + receivedAt: Date(timeIntervalSince1970: 20) + )) + registry.update(sessionID: sessionID) { record in + record.state = .ended + record.pid = 303 + } + let ended = try #require(registry.record(sessionID: sessionID)) + let observed = ObservedAgentSession( + sessionID: sessionID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 303, + workingDirectory: "/Users/example/project", + transcriptPath: nil, + sampledAt: Date(timeIntervalSince1970: 30) + ) + + let revived = registry.reviveEndedObservedSessionIfNeeded( + current: ended, + observed: observed, + now: Date(timeIntervalSince1970: 31) + ) + + #expect(!revived) + #expect(registry.record(sessionID: sessionID)?.state == .ended) + } + + @MainActor + @Test func unlistableEndedSessionPushesRemovalInsteadOfEndedDescriptor() throws { + let home = try temporaryHomeDirectory() + let coding = ChatWireCoding() + var emitted: [ChatSessionEventFrame] = [] + let service = AgentChatTranscriptService( + registry: AgentChatSessionRegistry(), + resolver: AgentChatTranscriptResolver(homeDirectory: home, environment: [:]), + hasEventSubscribers: { true }, + emitEventPayload: { payload in + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let frame = try? coding.decode(ChatSessionEventFrame.self, from: data) else { + return + } + emitted.append(frame) + } + ) + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let missingTranscript = home + .appendingPathComponent(".claude/projects/-Users-example-project", isDirectory: true) + .appendingPathComponent("\(sessionID).jsonl") + + service.noteHookEvent(WorkstreamEvent( + sessionId: sessionID, + hookEventName: .sessionStart, + source: "claude", + workspaceId: workspaceID, + surfaceId: surfaceID, + transcriptPath: missingTranscript.path, + cwd: "/Users/example/project", + ppid: 111, + receivedAt: Date(timeIntervalSince1970: 270) + )) + emitted.removeAll() + service.noteHookEvent(WorkstreamEvent( + sessionId: sessionID, + hookEventName: .sessionEnd, + source: "claude", + workspaceId: workspaceID, + surfaceId: surfaceID, + transcriptPath: missingTranscript.path, + cwd: "/Users/example/project", + ppid: nil, + receivedAt: Date(timeIntervalSince1970: 271) + )) + + #expect(emitted.contains { frame in + guard case .sessionRemoved = frame.event else { return false } + return frame.sessionID == sessionID + }) + #expect(!emitted.contains { frame in + guard case .stateChanged(.ended) = frame.event else { return false } + return frame.sessionID == sessionID + }) + #expect(!emitted.contains { frame in + guard case .descriptorChanged(let descriptor) = frame.event else { return false } + return frame.sessionID == sessionID && descriptor.state == .ended + }) + } + + @MainActor + @Test func endedCodexSessionPushesEndedStateInsteadOfRemoval() throws { + let coding = ChatWireCoding() + var emitted: [ChatSessionEventFrame] = [] + let service = AgentChatTranscriptService( + registry: AgentChatSessionRegistry(), + hasEventSubscribers: { true }, + emitEventPayload: { payload in + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let frame = try? coding.decode(ChatSessionEventFrame.self, from: data) else { + return + } + emitted.append(frame) + } + ) + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + + service.noteHookEvent(WorkstreamEvent( + sessionId: sessionID, + hookEventName: .sessionStart, + source: "codex", + workspaceId: workspaceID, + surfaceId: surfaceID, + ppid: 111, + receivedAt: Date(timeIntervalSince1970: 280) + )) + emitted.removeAll() + service.noteHookEvent(WorkstreamEvent( + sessionId: sessionID, + hookEventName: .sessionEnd, + source: "codex", + workspaceId: workspaceID, + surfaceId: surfaceID, + ppid: nil, + receivedAt: Date(timeIntervalSince1970: 281) + )) + + #expect(!emitted.contains { frame in + guard case .sessionRemoved = frame.event else { return false } + return frame.sessionID == sessionID + }) + #expect(emitted.contains { frame in + guard case .stateChanged(.ended) = frame.event else { return false } + return frame.sessionID == sessionID + }) + } + + private func temporaryHomeDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-chat-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift b/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift new file mode 100644 index 000000000000..923be3524a83 --- /dev/null +++ b/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift @@ -0,0 +1,499 @@ +import CMUXAgentLaunch +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +struct AgentChatSessionRegistryLifecycleTests { + @MainActor + @Test func hookStoreSeedDoesNotRestoreStalePIDOntoExistingLiveRecord() async throws { + let home = try temporaryHomeDirectory() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let (staleWorkspaceID, staleSurfaceID) = (UUID().uuidString, UUID().uuidString) + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let transcriptPath = "/Users/example/.claude/projects/-Users-example-project/\(sessionID).jsonl" + try writeClaudeHookStore( + home: home, + sessionID: sessionID, + workspaceID: staleWorkspaceID, + surfaceID: staleSurfaceID, + transcriptPath: transcriptPath, + pid: 444 + ) + let registry = AgentChatSessionRegistry( + hookStore: AgentChatHookSessionStore(homeDirectory: home) + ) + + registry.noteResumeInitiated( + sessionID: sessionID, + source: "claude", + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: "/Users/example/project" + ) + await registry.seedFromHookStores(agentSources: ["claude"]) + + let record = try #require(registry.record(sessionID: sessionID)) + #expect(record.workspaceID == workspaceID) + #expect(record.surfaceID == surfaceID) + #expect(record.transcriptPath == transcriptPath) + #expect(record.pid == nil) + #expect(record.state == .idle) + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == sessionID) + } + + @MainActor + @Test func endedPendingClaudeObservationRevivesForNewIdleProcess() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 111, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + registry.update(sessionID: pendingID) { record in + record.state = .ended + } + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 222, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + + let record = try #require(registry.record(sessionID: pendingID)) + #expect(record.state == .idle) + #expect(record.pid == 222) + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == pendingID) + } + + @MainActor + @Test func transcriptBackedEndedPendingClaudeIsPreservedWhenNewIdleProcessAppears() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let nextPendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID, pid: 222) + let transcriptPath = "/Users/example/.claude/projects/-Users-example-project/session.jsonl" + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 111, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + registry.update(sessionID: pendingID) { record in + record.transcriptPath = transcriptPath + record.state = .ended + } + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 222, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + + let ended = try #require(registry.record(sessionID: pendingID)) + let live = try #require(registry.record(sessionID: nextPendingID)) + #expect(ended.state == .ended) + #expect(ended.transcriptPath == transcriptPath) + #expect(live.state == .idle) + #expect(live.pid == 222) + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == nextPendingID) + } + + @MainActor + @Test func stalePendingClaudeObservationDoesNotCreateNewLiveAlias() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let nextPendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID, pid: 222) + let transcriptPath = "/Users/example/.claude/projects/-Users-example-project/session.jsonl" + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 111, + workingDirectory: "/Users/example/project", + transcriptPath: nil, + sampledAt: Date(timeIntervalSince1970: 100) + ), + ]) + registry.update(sessionID: pendingID) { record in + record.transcriptPath = transcriptPath + record.state = .ended + } + let endedAt = try #require(registry.record(sessionID: pendingID)?.endedAt) + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 222, + workingDirectory: "/Users/example/project", + transcriptPath: nil, + sampledAt: endedAt.addingTimeInterval(-1) + ), + ]) + + let ended = try #require(registry.record(sessionID: pendingID)) + #expect(ended.state == .ended) + #expect(ended.pid == 111) + #expect(registry.record(sessionID: nextPendingID) == nil) + #expect(registry.liveSession(surfaceID: surfaceID) == nil) + } + + @MainActor + @Test func hookBackedEndedPendingClaudeIsPreservedWhenNewIdleProcessAppears() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let nextPendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID, pid: 222) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 111, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + registry.update(sessionID: pendingID) { record in + record.rememberHookStoreSessionID(realSessionID) + record.state = .ended + } + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 222, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + + let ended = try #require(registry.record(sessionID: pendingID)) + let live = try #require(registry.record(sessionID: nextPendingID)) + #expect(ended.state == .ended) + #expect(ended.hookStoreSessionID == realSessionID) + #expect(live.state == .idle) + #expect(live.pid == 222) + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == nextPendingID) + } + + @MainActor + @Test func endedCodexObservationRevivesRealSessionID() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: sessionID, + agentKind: .codex, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 111, + workingDirectory: "/Users/example/project", + transcriptPath: "/Users/example/.codex/sessions/rollout-\(sessionID).jsonl" + ), + ]) + registry.update(sessionID: sessionID) { record in + record.state = .ended + } + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: sessionID, + agentKind: .codex, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 222, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + + let record = try #require(registry.record(sessionID: sessionID)) + #expect(record.state == .idle) + #expect(record.pid == 222) + #expect(record.transcriptPath == "/Users/example/.codex/sessions/rollout-\(sessionID).jsonl") + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == sessionID) + } + + @MainActor + @Test func staleProcessObservationDoesNotReviveEndedSession() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: sessionID, + agentKind: .codex, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 111, + workingDirectory: "/Users/example/project", + transcriptPath: "/Users/example/.codex/sessions/rollout-\(sessionID).jsonl", + sampledAt: Date(timeIntervalSince1970: 100) + ), + ]) + registry.update(sessionID: sessionID) { record in + record.state = .ended + } + let endedAt = try #require(registry.record(sessionID: sessionID)?.endedAt) + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: sessionID, + agentKind: .codex, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 222, + workingDirectory: "/Users/example/project", + transcriptPath: nil, + sampledAt: endedAt.addingTimeInterval(-1) + ), + ]) + + let record = try #require(registry.record(sessionID: sessionID)) + #expect(record.state == .ended) + #expect(record.pid == 111) + #expect(registry.liveSession(surfaceID: surfaceID) == nil) + } + + @MainActor + @Test func pendingClaudeAliasRefreshesFromRealHookStoreSessionID() async throws { + let home = try temporaryHomeDirectory() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let transcriptPath = "/Users/example/.claude/projects/-Users-example-project/\(realSessionID).jsonl" + try writeClaudeHookStore( + home: home, + sessionID: realSessionID, + workspaceID: workspaceID, + surfaceID: surfaceID, + transcriptPath: transcriptPath, + pid: 222 + ) + let registry = AgentChatSessionRegistry(hookStore: AgentChatHookSessionStore(homeDirectory: home)) + + registry.noteResumeInitiated( + sessionID: pendingID, + source: "claude", + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: "/Users/example/project" + ) + registry.noteHookEvent(WorkstreamEvent( + sessionId: realSessionID, + hookEventName: .sessionStart, + source: "claude", + workspaceId: workspaceID, + surfaceId: surfaceID, + transcriptPath: nil, + cwd: "/Users/example/project", + ppid: 333, + receivedAt: Date(timeIntervalSince1970: 150) + )) + + let refreshed = try #require(await registry.refreshBindingsFromHookStore(sessionID: pendingID)) + #expect(refreshed.transcriptPath == transcriptPath) + #expect(refreshed.pid == 333) + } + + @MainActor + @Test func endedSessionWithMissingTranscriptIsNotListableForMobileChat() throws { + let home = try temporaryHomeDirectory() + let service = AgentChatTranscriptService( + registry: AgentChatSessionRegistry(), + resolver: AgentChatTranscriptResolver(homeDirectory: home, environment: [:]) + ) + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let transcriptURL = home + .appendingPathComponent(".claude/projects/-Users-example-project", isDirectory: true) + .appendingPathComponent("\(sessionID).jsonl") + let record = AgentChatSessionRecord( + sessionID: sessionID, + agentKind: .claude, + workspaceID: UUID().uuidString, + surfaceID: UUID().uuidString, + workingDirectory: "/Users/example/project", + transcriptPath: transcriptURL.path, + state: .ended, + lastActivityAt: Date(), + title: nil, + pid: nil + ) + + #expect(!service.hasBoundedReadableTranscript(record)) + + try FileManager.default.createDirectory( + at: transcriptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "{}\n".write(to: transcriptURL, atomically: true, encoding: .utf8) + + #expect(service.hasBoundedReadableTranscript(record)) + _ = service.noteHookEvent(WorkstreamEvent( + sessionId: sessionID, hookEventName: .sessionEnd, source: "claude", + workspaceId: record.workspaceID, surfaceId: record.surfaceID, + transcriptPath: transcriptURL.path, cwd: "/Users/example/project", ppid: nil, + receivedAt: Date(timeIntervalSince1970: 250) + )) + let cachedRecord = try #require(service.sessionRecord(sessionID: sessionID)) + #expect(cachedRecord.state == .ended) + #expect(service.shouldListEndedSession(cachedRecord)) + } + + @MainActor + @Test func endedCodexSessionListabilityKeepsFallbackRowsWithoutScanningHistory() throws { + let home = try temporaryHomeDirectory() + let service = AgentChatTranscriptService( + registry: AgentChatSessionRegistry(), + resolver: AgentChatTranscriptResolver(homeDirectory: home, environment: [:]) + ) + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let transcriptURL = home + .appendingPathComponent(".codex/sessions/2026/06/30", isDirectory: true) + .appendingPathComponent("rollout-2026-06-30T00-00-00-\(sessionID).jsonl") + try FileManager.default.createDirectory( + at: transcriptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "{}\n".write(to: transcriptURL, atomically: true, encoding: .utf8) + let record = AgentChatSessionRecord( + sessionID: sessionID, + agentKind: .codex, + workspaceID: UUID().uuidString, + surfaceID: UUID().uuidString, + workingDirectory: "/Users/example/project", + transcriptPath: nil, + state: .ended, + lastActivityAt: Date(), + title: nil, + pid: nil + ) + + #expect(!service.hasBoundedReadableTranscript(record)) + #expect(service.shouldListEndedSession(record)) + } + + @MainActor + @Test func pendingClaudeAliasUsesRealHookSessionIDForFallbackTranscript() throws { + let home = try temporaryHomeDirectory() + let service = AgentChatTranscriptService( + registry: AgentChatSessionRegistry(), + resolver: AgentChatTranscriptResolver(homeDirectory: home, environment: [:]) + ) + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let transcriptURL = home + .appendingPathComponent(".claude/projects/-Users-example-project", isDirectory: true) + .appendingPathComponent("\(realSessionID).jsonl") + try FileManager.default.createDirectory( + at: transcriptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "{}\n".write(to: transcriptURL, atomically: true, encoding: .utf8) + var record = AgentChatSessionRecord( + sessionID: pendingID, + agentKind: .claude, + workspaceID: UUID().uuidString, + surfaceID: surfaceID, + workingDirectory: "/Users/example/project", + transcriptPath: nil, + state: .ended, + lastActivityAt: Date(), + title: nil, + pid: nil + ) + record.rememberHookStoreSessionID(realSessionID) + + #expect(service.hasBoundedReadableTranscript(record)) + } + + private func temporaryHomeDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-chat-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func writeClaudeHookStore( + home: URL, + sessionID: String, + workspaceID: String, + surfaceID: String, + transcriptPath: String, + pid: Int + ) throws { + let directory = home.appendingPathComponent(".cmuxterm", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let payload: [String: Any] = [ + "sessions": [ + sessionID: [ + "workspaceId": workspaceID, + "surfaceId": surfaceID, + "cwd": "/Users/example/project", + "transcriptPath": transcriptPath, + "pid": pid, + "updatedAt": 140.0, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + try data.write(to: directory.appendingPathComponent("claude-hook-sessions.json")) + } +} diff --git a/cmuxTests/AgentChatSessionRegistryObservationReviewRegressionTests.swift b/cmuxTests/AgentChatSessionRegistryObservationReviewRegressionTests.swift new file mode 100644 index 000000000000..54bfc7ccd54b --- /dev/null +++ b/cmuxTests/AgentChatSessionRegistryObservationReviewRegressionTests.swift @@ -0,0 +1,494 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +struct AgentChatSessionRegistryObservationReviewRegressionTests { + @Test func mobileChatObserverPrefersRealClaudeChildForSameSession() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 101, + parentPID: 10, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID + ), + topProcess( + pid: 202, + parentPID: 101, + name: "claude", + path: "/Users/example/.claude/local/claude", + workspaceID: workspaceID, + surfaceID: surfaceID + ), + ], + sampledAt: Date(timeIntervalSince1970: 101), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + switch pid { + case 101: + CmuxTopProcessArguments( + arguments: [ + "node", + "/Users/example/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js", + ], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": sessionID, + "CMUX_AGENT_LAUNCH_CWD": "/Users/example/project", + ] + ) + case 202: + CmuxTopProcessArguments( + arguments: ["claude"], + environment: [ + "CLAUDE_CODE_SESSION_ID": sessionID, + "CMUX_AGENT_LAUNCH_CWD": "/Users/example/project", + ] + ) + default: + nil + } + }, + codexRolloutPath: { _ in nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == sessionID) + #expect(session.pid == 202) + } + + @Test func mobileChatObserverIgnoresInheritedLaunchKindOnChildTool() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 101, + parentPID: 10, + name: "claude", + path: "/opt/homebrew/bin/claude", + workspaceID: workspaceID, + surfaceID: surfaceID, + processGroupID: 101, + terminalProcessGroupID: 101 + ), + topProcess( + pid: 202, + parentPID: 101, + name: "git", + path: "/usr/bin/git", + workspaceID: workspaceID, + surfaceID: surfaceID, + processGroupID: 101, + terminalProcessGroupID: 101 + ), + ], + sampledAt: Date(timeIntervalSince1970: 102), + includesProcessDetails: true + ) + + let details: (Int) -> CmuxTopProcessArguments? = { pid in + switch pid { + case 101: + CmuxTopProcessArguments( + arguments: ["claude"], + environment: ["CLAUDE_CODE_SESSION_ID": sessionID] + ) + case 202: + CmuxTopProcessArguments( + arguments: ["git", "status"], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": sessionID, + ] + ) + default: + nil + } + } + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: details, + codexRolloutPath: { _ in nil } + ) + let livePID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + matchingSessionIDs: [sessionID], + processArgumentsAndEnvironment: details + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == sessionID) + #expect(session.pid == 101) + #expect(livePID == 101) + } + + @Test func mobileChatObserverDetectsHostedClaudeUnderScopedShellRoot() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 101, + parentPID: 10, + name: "zsh", + path: "/bin/zsh", + workspaceID: workspaceID, + surfaceID: surfaceID, + processGroupID: 101, + terminalProcessGroupID: 202 + ), + topProcess( + pid: 202, + parentPID: 101, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID, + processGroupID: 202, + terminalProcessGroupID: 202 + ), + ], + sampledAt: Date(timeIntervalSince1970: 124), + includesProcessDetails: true + ) + let details: (Int) -> CmuxTopProcessArguments? = { pid in + switch pid { + case 101: + CmuxTopProcessArguments(arguments: ["-zsh"], environment: [:]) + case 202: + CmuxTopProcessArguments( + arguments: [ + "node", + "/Users/example/.cmux-agent-wrapper/subrouter.js", + ], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": sessionID, + ] + ) + default: + nil + } + } + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: details, + codexRolloutPath: { _ in nil } + ) + let livePID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + matchingSessionIDs: [sessionID], + processArgumentsAndEnvironment: details + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == sessionID) + #expect(session.pid == 202) + #expect(livePID == 202) + } + + @Test func mobileChatObserverIgnoresInheritedLaunchKindOnUnrelatedForegroundChild() { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 101, + parentPID: 10, + name: "zsh", + path: "/bin/zsh", + workspaceID: workspaceID, + surfaceID: surfaceID, + processGroupID: 101, + terminalProcessGroupID: 202 + ), + topProcess( + pid: 202, + parentPID: 101, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID, + processGroupID: 202, + terminalProcessGroupID: 202 + ), + ], + sampledAt: Date(timeIntervalSince1970: 125), + includesProcessDetails: true + ) + let details: (Int) -> CmuxTopProcessArguments? = { pid in + switch pid { + case 101: + CmuxTopProcessArguments(arguments: ["-zsh"], environment: [:]) + case 202: + CmuxTopProcessArguments( + arguments: ["node", "server.js"], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": sessionID, + ] + ) + default: + nil + } + } + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: details, + codexRolloutPath: { _ in nil } + ) + let livePID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + matchingSessionIDs: [sessionID], + processArgumentsAndEnvironment: details + ) + + #expect(observed.isEmpty) + #expect(livePID == nil) + } + + @Test func mobileChatObserverUsesPendingAliasForClaudeForkResumeParent() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let parentSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let pendingSessionID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID.uuidString) + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess(pid: 303, parentPID: 101, name: "claude", path: "/opt/homebrew/bin/claude", workspaceID: workspaceID, surfaceID: surfaceID), + ], + sampledAt: Date(timeIntervalSince1970: 303), + includesProcessDetails: true + ) + let details: (Int) -> CmuxTopProcessArguments? = { pid in + guard pid == 303 else { return nil } + return CmuxTopProcessArguments( + arguments: ["claude", "--resume", parentSessionID, "--fork-session=true"], + environment: ["CLAUDE_CODE_SESSION_ID": parentSessionID, "CMUX_AGENT_LAUNCH_CWD": "/Users/example/project"] + ) + } + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions(in: snapshot, processArgumentsAndEnvironment: details, codexRolloutPath: { _ in nil }) + let liveParentPID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + matchingSessionIDs: [parentSessionID], + processArgumentsAndEnvironment: details + ) + let livePendingPID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + matchingSessionIDs: [pendingSessionID], + allowUnidentifiedFallback: true, + processArgumentsAndEnvironment: details + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == pendingSessionID) + #expect(session.agentKind == .claude) + #expect(session.pid == 303) + #expect(liveParentPID == nil) + #expect(livePendingPID == 303) + } + + @Test func mobileChatObserverDetectsClaudeExeRuntimeProcess() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "1258bb73-b1b8-469e-910a-61266f4dfc44" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 96441, + parentPID: 93888, + name: "claude.exe", + path: "/Users/example/.local/share/claude/versions/2.1.199/claude.exe", + workspaceID: workspaceID, + surfaceID: surfaceID + ), + ], + sampledAt: Date(timeIntervalSince1970: 1258), + includesProcessDetails: true + ) + let details: (Int) -> CmuxTopProcessArguments? = { pid in + guard pid == 96441 else { return nil } + return CmuxTopProcessArguments( + arguments: ["claude.exe"], + environment: [ + "CLAUDE_CODE_SESSION_ID": sessionID, + "PWD": "/Users/example/project", + ] + ) + } + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: details, + codexRolloutPath: { _ in nil } + ) + let livePID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + matchingSessionIDs: [sessionID], + processArgumentsAndEnvironment: details + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == sessionID) + #expect(session.agentKind == .claude) + #expect(session.workspaceID == workspaceID.uuidString) + #expect(session.surfaceID == surfaceID.uuidString) + #expect(session.pid == 96441) + #expect(session.workingDirectory == "/Users/example/project") + #expect(livePID == 96441) + } + + @Test func mobileChatObserverCreatesPendingClaudeExeWhenSessionIdentityIsUnavailable() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 54045, + parentPID: 51297, + name: "claude.exe", + path: "/Users/example/.local/share/claude/versions/2.1.199/claude.exe", + workspaceID: workspaceID, + surfaceID: surfaceID + ), + ], + sampledAt: Date(timeIntervalSince1970: 540), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { _ in nil }, + codexRolloutPath: { _ in nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID.uuidString)) + #expect(session.agentKind == .claude) + #expect(session.workspaceID == workspaceID.uuidString) + #expect(session.surfaceID == surfaceID.uuidString) + #expect(session.pid == 54045) + #expect(session.workingDirectory == nil) + } + + @Test func canceledObservationScanReturnsBeforeProcessDetailReads() async { + let workspaceID = UUID() + let surfaceID = UUID() + let detailReads = LockedCounter() + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 7001, + parentPID: 7000, + name: "claude.exe", + path: "/Users/example/.local/share/claude/versions/2.1.199/claude.exe", + workspaceID: workspaceID, + surfaceID: surfaceID + ), + ], + sampledAt: Date(timeIntervalSince1970: 7001), + includesProcessDetails: true + ) + + let scan = Task.detached { + try? await Task.sleep(for: .milliseconds(10)) + return AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { _ in + detailReads.increment() + return CmuxTopProcessArguments(arguments: ["claude.exe"], environment: [:]) + }, + codexRolloutPath: { _ in nil } + ) + } + scan.cancel() + + let observed = await scan.value + #expect(observed.isEmpty) + #expect(detailReads.value == 0) + } + + private func topProcess( + pid: Int, + parentPID: Int, + name: String, + path: String?, + workspaceID: UUID, + surfaceID: UUID, + processGroupID: Int? = nil, + terminalProcessGroupID: Int? = nil + ) -> CmuxTopProcessInfo { + CmuxTopProcessInfo( + pid: pid, + parentPID: parentPID, + name: name, + path: path, + ttyDevice: nil, + cmuxWorkspaceID: workspaceID, + cmuxSurfaceID: surfaceID, + cmuxAttributionReason: "test", + processGroupID: processGroupID ?? pid, + terminalProcessGroupID: terminalProcessGroupID ?? pid, + cpuPercent: 0, + residentBytes: 1, + virtualBytes: 1, + threadCount: 1 + ) + } +} + +private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.lock() + defer { lock.unlock() } + return count + } + + func increment() { + lock.lock() + count += 1 + lock.unlock() + } +} diff --git a/cmuxTests/AgentChatSessionRegistryObservationTests.swift b/cmuxTests/AgentChatSessionRegistryObservationTests.swift new file mode 100644 index 000000000000..8c417a6cd905 --- /dev/null +++ b/cmuxTests/AgentChatSessionRegistryObservationTests.swift @@ -0,0 +1,499 @@ +import Foundation +import Testing +import CMUXAgentLaunch + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +struct AgentChatSessionRegistryObservationTests { + @Test func mobileChatObserverDetectsNodeHostedClaudeFromProcessDetails() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 101, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 100), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 101 else { return nil } + return CmuxTopProcessArguments( + arguments: [ + "node", + "/Users/example/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js", + ], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": sessionID, + "CMUX_AGENT_LAUNCH_CWD": "/Users/example/project", + ] + ) + }, + codexRolloutPath: { _ in nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == sessionID) + #expect(session.agentKind == .claude) + #expect(session.workspaceID == workspaceID.uuidString) + #expect(session.surfaceID == surfaceID.uuidString) + #expect(session.pid == 101) + #expect(session.workingDirectory == "/Users/example/project") + } + + @Test func mobileChatObserverCreatesPendingClaudeSessionWithoutSessionIdentity() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 111, + name: "claude", + path: "/opt/homebrew/bin/claude", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 110), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 111 else { return nil } + return CmuxTopProcessArguments( + arguments: ["claude", "--settings", "{}"], + environment: ["CMUX_AGENT_LAUNCH_CWD": "/Users/example/project"] + ) + }, + codexRolloutPath: { _ in nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(session.sessionID == AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID.uuidString)) + #expect(session.agentKind == .claude) + #expect(session.workspaceID == workspaceID.uuidString) + #expect(session.surfaceID == surfaceID.uuidString) + #expect(session.pid == 111) + #expect(session.workingDirectory == "/Users/example/project") + } + + @MainActor + @Test func claudeHooksAdoptSameSurfacePendingSession() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let transcriptPath = "/Users/example/.claude/projects/-Users-example-project/\(realSessionID).jsonl" + + registry.noteResumeInitiated( + sessionID: pendingID, + source: "claude", + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: "/Users/example/project" + ) + + registry.noteHookEvent(WorkstreamEvent( + sessionId: realSessionID, + hookEventName: .sessionStart, + source: "claude", + workspaceId: workspaceID, + surfaceId: surfaceID, + transcriptPath: transcriptPath, + cwd: "/Users/example/project", + ppid: 222, + receivedAt: Date(timeIntervalSince1970: 120) + )) + + let record = try #require(registry.record(sessionID: pendingID)) + #expect(registry.record(sessionID: realSessionID) == nil) + #expect(record.sessionID == pendingID) + #expect(record.transcriptPath == transcriptPath) + #expect(record.pid == 222) + #expect(record.state == .idle) + } + + @MainActor + @Test func observedRealClaudeSessionPreservesHistoryIDWhenFoldedIntoPendingAlias() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + + registry.noteResumeInitiated( + sessionID: pendingID, + source: "claude", + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: "/Users/example/project" + ) + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: realSessionID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 333, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + + let record = try #require(registry.record(sessionID: pendingID)) + #expect(registry.record(sessionID: realSessionID) == nil) + #expect(record.hookStoreSessionID == realSessionID) + #expect(record.pid == 333) + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == pendingID) + } + + @MainActor + @Test func pendingClaudeObservationBackfillsExistingRealSession() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + + registry.noteResumeInitiated( + sessionID: realSessionID, + source: "claude", + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: "/Users/example/project" + ) + + registry.applyObservedSessions([ + ObservedAgentSession( + sessionID: pendingID, + agentKind: .claude, + surfaceID: surfaceID, + workspaceID: workspaceID, + pid: 333, + workingDirectory: "/Users/example/project", + transcriptPath: nil + ), + ]) + + let record = try #require(registry.record(sessionID: realSessionID)) + #expect(registry.record(sessionID: pendingID) == nil) + #expect(record.hookStoreSessionID == nil) + #expect(record.hookStoreLookupSessionID == realSessionID) + #expect(record.pid == 333) + #expect(record.surfaceID == surfaceID) + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == realSessionID) + } + + @MainActor + @Test func realClaudeHookRemovesPendingAliasWhenRealRecordAlreadyExists() throws { + let registry = AgentChatSessionRegistry() + let workspaceID = UUID().uuidString + let surfaceID = UUID().uuidString + let pendingID = AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID) + let realSessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + var removedIDs: [String] = [] + registry.onRecordRemoved = { removedIDs.append($0.sessionID) } + + registry.noteResumeInitiated( + sessionID: pendingID, + source: "claude", + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: "/Users/example/project" + ) + registry.noteResumeInitiated( + sessionID: realSessionID, + source: "claude", + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: "/Users/example/project" + ) + + registry.noteHookEvent(WorkstreamEvent( + sessionId: realSessionID, + hookEventName: .sessionStart, + source: "claude", + workspaceId: workspaceID, + surfaceId: surfaceID, + transcriptPath: "/Users/example/.claude/projects/-Users-example-project/\(realSessionID).jsonl", + cwd: "/Users/example/project", + ppid: 555, + receivedAt: Date(timeIntervalSince1970: 130) + )) + + let record = try #require(registry.record(sessionID: realSessionID)) + #expect(registry.record(sessionID: pendingID) == nil) + #expect(removedIDs == [pendingID]) + #expect(record.pid == 555) + #expect(registry.liveSession(surfaceID: surfaceID)?.sessionID == realSessionID) + } + + @Test func mobileChatObserverStillDetectsDirectCodexFromRolloutFile() throws { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "018ff5fe-3f91-79d0-99aa-a6a2d7c17b22" + let rolloutPath = "/Users/example/.codex/sessions/2026/06/29/rollout-2026-06-29T12-00-00-\(sessionID).jsonl" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 202, + name: "codex", + path: "/opt/homebrew/bin/codex", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 200), + includesProcessDetails: true + ) + + var detailReadCount = 0 + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { _ in + detailReadCount += 1 + return nil + }, + codexRolloutPath: { pid in pid == 202 ? rolloutPath : nil } + ) + + let session = try #require(observed.first) + #expect(observed.count == 1) + #expect(detailReadCount == 0) + #expect(session.sessionID == sessionID) + #expect(session.agentKind == .codex) + #expect(session.workspaceID == workspaceID.uuidString) + #expect(session.surfaceID == surfaceID.uuidString) + #expect(session.pid == 202) + #expect(session.transcriptPath == rolloutPath) + } + + @Test func mobileChatObserverIgnoresClaudeChildProcessWithInheritedEnvironment() { + let workspaceID = UUID() + let surfaceID = UUID() + let sessionID = "24ec0052-450c-4914-b1dd-2ee80d4bc84b" + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 303, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 300), + includesProcessDetails: true + ) + + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { pid in + guard pid == 303 else { return nil } + return CmuxTopProcessArguments( + arguments: ["node", "server.js"], + environment: [ + "CLAUDE_CODE_SESSION_ID": sessionID, + ] + ) + }, + codexRolloutPath: { _ in nil } + ) + + #expect(observed.isEmpty) + } + + @Test func mobileChatLivenessRecognizesNodeHostedClaudeFromProcessDetails() { + let workspaceID = UUID() + let surfaceID = UUID() + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 505, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 500), + includesProcessDetails: true + ) + + let livePID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + processArgumentsAndEnvironment: { pid in + guard pid == 505 else { return nil } + return CmuxTopProcessArguments( + arguments: [ + "node", + "/Users/example/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js", + ], + environment: [ + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CLAUDE_CODE_SESSION_ID": "24ec0052-450c-4914-b1dd-2ee80d4bc84b", + ] + ) + } + ) + + #expect(livePID == 505) + } + + @Test func mobileChatLivenessIgnoresClaudeChildProcessWithInheritedEnvironment() { + let workspaceID = UUID() + let surfaceID = UUID() + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 606, + name: "node", + path: "/opt/homebrew/bin/node", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 600), + includesProcessDetails: true + ) + + let livePID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + processArgumentsAndEnvironment: { pid in + guard pid == 606 else { return nil } + return CmuxTopProcessArguments( + arguments: ["node", "server.js"], + environment: [ + "CLAUDE_CODE_SESSION_ID": "24ec0052-450c-4914-b1dd-2ee80d4bc84b", + ] + ) + } + ) + + #expect(livePID == nil) + } + + @Test func mobileChatLivenessFallsBackToUnidentifiedClaudeProcess() { + let workspaceID = UUID() + let surfaceID = UUID() + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 707, + name: "claude", + path: "/opt/homebrew/bin/claude", + workspaceID: workspaceID, + surfaceID: surfaceID + ), + ], + sampledAt: Date(timeIntervalSince1970: 700), + includesProcessDetails: true + ) + + let livePID = AgentChatSessionRegistry.liveAgentPID( + in: snapshot, + surfaceID: surfaceID.uuidString, + kind: .claude, + matchingSessionIDs: [AgentChatSessionRegistry.pendingClaudeSessionID(surfaceID: surfaceID.uuidString)], + allowUnidentifiedFallback: true, + processArgumentsAndEnvironment: { _ in CmuxTopProcessArguments(arguments: ["claude"], environment: [:]) } + ) + + #expect(livePID == 707) + } + + @Test func endedLifecycleIgnoresDelayedNonStartEvents() { + let delayed = WorkstreamEvent( + sessionId: "session", + hookEventName: .userPromptSubmit, + source: "claude", + receivedAt: Date(timeIntervalSince1970: 1) + ) + let restart = WorkstreamEvent(sessionId: "session", hookEventName: .sessionStart, source: "claude") + #expect(AgentChatSessionRegistry.nextState(previous: .ended, event: delayed) == .ended) + #expect(AgentChatSessionRegistry.nextState(previous: .ended, event: restart) == .idle) + } + + @Test func mobileChatObserverSkipsUnambiguousNonAgentWithoutReadingDetails() { + let workspaceID = UUID() + let surfaceID = UUID() + let snapshot = CmuxTopProcessSnapshot( + processes: [ + topProcess( + pid: 404, + name: "server", + path: "/usr/local/bin/server", + workspaceID: workspaceID, + surfaceID: surfaceID + ) + ], + sampledAt: Date(timeIntervalSince1970: 400), + includesProcessDetails: true + ) + + var detailReadCount = 0 + let observed = AgentChatSessionRegistry.scanObservedAgentSessions( + in: snapshot, + processArgumentsAndEnvironment: { _ in + detailReadCount += 1 + return nil + }, + codexRolloutPath: { _ in nil } + ) + + #expect(observed.isEmpty) + #expect(detailReadCount == 0) + } + + private func topProcess( + pid: Int, + name: String, + path: String?, + workspaceID: UUID, + surfaceID: UUID, + isForeground: Bool = true + ) -> CmuxTopProcessInfo { + let processGroupID = pid + return CmuxTopProcessInfo( + pid: pid, + parentPID: 1, + name: name, + path: path, + ttyDevice: nil, + cmuxWorkspaceID: workspaceID, + cmuxSurfaceID: surfaceID, + cmuxAttributionReason: "test", + processGroupID: processGroupID, + terminalProcessGroupID: isForeground ? processGroupID : processGroupID + 1, + cpuPercent: 0, + residentBytes: 1, + virtualBytes: 1, + threadCount: 1 + ) + } + +} diff --git a/cmuxTests/ClaudeHookFeedTelemetrySwiftTests.swift b/cmuxTests/ClaudeHookFeedTelemetrySwiftTests.swift new file mode 100644 index 000000000000..fb59b5e86f61 --- /dev/null +++ b/cmuxTests/ClaudeHookFeedTelemetrySwiftTests.swift @@ -0,0 +1,369 @@ +import Darwin +import Dispatch +import Foundation +import Testing + +@Suite(.serialized) +struct ClaudeHookFeedTelemetrySwiftTests { + @Test func sessionStartFeedTelemetryUsesResolvedTTYSurface() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-feed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let socketPath = makeSocketPath("feed") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = FeedTelemetryMockState() + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let workspaceID = "11111111-1111-1111-1111-111111111111" + let leakedSurfaceID = "22222222-2222-2222-2222-222222222222" + let resolvedSurfaceID = "33333333-3333-3333-3333-333333333333" + let ttyName = "ttys-claude-feed-surface" + let feedSeen = DispatchSemaphore(value: 0) + startServer( + listenerFD: listenerFD, + state: state, + workspaceID: workspaceID, + focusedSurfaceID: leakedSurfaceID, + ttyName: ttyName, + resolvedSurfaceID: resolvedSurfaceID, + feedSeen: feedSeen + ) + + let environment = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceID, + "CMUX_SURFACE_ID": leakedSurfaceID, + "CMUX_CLI_TTY_NAME": ttyName, + "CMUX_CLAUDE_HOOK_STATE_PATH": root.appendingPathComponent("claude-hook-sessions.json").path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_CLAUDE_HOOK_SENTRY_DISABLED": "1", + "CMUX_AGENT_LAUNCH_KIND": "claude", + "CMUX_AGENT_LAUNCH_EXECUTABLE": "/usr/local/bin/claude", + "CMUX_AGENT_LAUNCH_CWD": root.path, + "CMUX_AGENT_LAUNCH_ARGV_B64": base64NULSeparated(["/usr/local/bin/claude"]), + ] + let cliPath = try BundledCLITestSupport.bundledCLIPath(for: BundledCLILinkageTests.self) + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "claude", "session-start"], + environment: environment, + standardInput: #"{"session_id":"claude-feed-session","source":"clear","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"#, + timeout: 5 + ) + + #expect(result.timedOut == false, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "OK\n") + #expect(feedSeen.wait(timeout: .now() + 5) == .success, "Expected feed.push, saw \(state.commandsSnapshot())") + let event = try #require( + state.feedEventsSnapshot().last { $0["hook_event_name"] as? String == "SessionStart" }, + "Expected SessionStart feed telemetry, saw \(state.commandsSnapshot())" + ) + #expect( + event["surface_id"] as? String == resolvedSurfaceID, + "Feed telemetry must use the resolved agent TTY surface, not leaked CMUX_SURFACE_ID; event=\(event)" + ) + } +} + +private final class FeedTelemetryMockState: @unchecked Sendable { + private let lock = NSLock() + private var commands: [String] = [] + private var feedEvents: [[String: Any]] = [] + + func appendCommand(_ command: String) { + lock.lock() + commands.append(command) + lock.unlock() + } + + func appendFeedEvent(_ event: [String: Any]) { + lock.lock() + feedEvents.append(event) + lock.unlock() + } + + func commandsSnapshot() -> [String] { + lock.lock() + let value = commands + lock.unlock() + return value + } + + func feedEventsSnapshot() -> [[String: Any]] { + lock.lock() + let value = feedEvents + lock.unlock() + return value + } +} + +private struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool +} + +private func startServer( + listenerFD: Int32, + state: FeedTelemetryMockState, + workspaceID: String, + focusedSurfaceID: String, + ttyName: String, + resolvedSurfaceID: String, + feedSeen: DispatchSemaphore +) { + DispatchQueue.global(qos: .userInitiated).async { + while true { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + if errno == EINTR { continue } + return + } + DispatchQueue.global(qos: .userInitiated).async { + handleClient( + clientFD, + state: state, + workspaceID: workspaceID, + focusedSurfaceID: focusedSurfaceID, + ttyName: ttyName, + resolvedSurfaceID: resolvedSurfaceID, + feedSeen: feedSeen + ) + } + } + } +} + +private func handleClient( + _ clientFD: Int32, + state: FeedTelemetryMockState, + workspaceID: String, + focusedSurfaceID: String, + ttyName: String, + resolvedSurfaceID: String, + feedSeen: DispatchSemaphore +) { + defer { Darwin.close(clientFD) } + + func writeResponse(_ response: String) { + let line = response + "\n" + _ = line.withCString { pointer in + Darwin.write(clientFD, pointer, strlen(pointer)) + } + } + + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + return + } + if count == 0 { return } + pending.append(buffer, count: count) + + while let newlineRange = pending.firstRange(of: Data([0x0A])) { + let lineData = pending.subdata(in: 0.. String { + guard let payload = jsonObject(line), + let method = payload["method"] as? String else { + return "OK" + } + if method == "feed.push" { + if let params = payload["params"] as? [String: Any], + let event = params["event"] as? [String: Any] { + state.appendFeedEvent(event) + feedSeen.signal() + } + return "OK" + } + guard let id = payload["id"] as? String else { + return "OK" + } + switch method { + case "surface.list": + return v2Response(id: id, ok: true, result: [ + "surfaces": [ + ["id": focusedSurfaceID, "ref": "surface:1", "focused": true], + ["id": resolvedSurfaceID, "ref": "surface:2", "focused": false], + ], + ]) + case "debug.terminals": + return v2Response(id: id, ok: true, result: [ + "terminals": [[ + "tty": ttyName, + "workspace_id": workspaceID, + "surface_id": resolvedSurfaceID, + ]], + ]) + case "surface.resume.set": + return v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + default: + return v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": method]) + } +} + +private func makeSocketPath(_ name: String) -> String { + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cli-\(name)-\(shortID).sock") + .path +} + +private func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "cmux.tests", code: Int(errno)) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + let utf8 = Array(path.utf8) + guard utf8.count < maxPathLength else { + Darwin.close(fd) + throw NSError(domain: "cmux.tests", code: Int(ENAMETOOLONG)) + } + _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { pathBuffer in + for index in 0...size)) + } + } + guard bindResult == 0, Darwin.listen(fd, 8) == 0 else { + let code = errno + Darwin.close(fd) + throw NSError(domain: "cmux.tests", code: Int(code)) + } + return fd +} + +private func base64NULSeparated(_ values: [String]) -> String { + var bytes: [UInt8] = [] + for value in values { + bytes.append(contentsOf: value.utf8) + bytes.append(0) + } + return Data(bytes).base64EncodedString() +} + +private func runProcess( + executablePath: String, + arguments: [String], + environment: [String: String], + standardInput: String? = nil, + timeout: TimeInterval +) -> ProcessRunResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + var stdinPipe: Pipe? + if let standardInput { + let input = Pipe() + process.standardInput = input + stdinPipe = input + input.fileHandleForWriting.write(Data(standardInput.utf8)) + input.fileHandleForWriting.closeFile() + } + + let exitSignal = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in exitSignal.signal() } + do { + try process.run() + } catch { + return ProcessRunResult(status: -1, stdout: "", stderr: "\(error)", timedOut: false) + } + _ = stdinPipe + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + + let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderr.fileHandleForReading.readDataToEndOfFile() + return ProcessRunResult( + status: process.terminationStatus, + stdout: String(data: stdoutData, encoding: .utf8) ?? "", + stderr: String(data: stderrData, encoding: .utf8) ?? "", + timedOut: timedOut + ) +} + +private func jsonObject(_ line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] +} + +private func v2Response( + id: String, + ok: Bool, + result: [String: Any]? = nil, + error: [String: Any]? = nil +) -> String { + var object: [String: Any] = [ + "id": id, + "ok": ok, + ] + if let result { object["result"] = result } + if let error { object["error"] = error } + let data = try? JSONSerialization.data(withJSONObject: object) + return data.flatMap { String(data: $0, encoding: .utf8) } ?? #"{"ok":false}"# +}