#if PHONE_LINK import AppKit import CloudKit import Combine // MARK: - iPhone plan, step 4: publish agent sessions to iCloud // // One `Session` record per agent pill (VS Code / Claude Code, Cursor, Codex, // Antigravity, Gemini CLI…) in the private zone `Coucou`, so the iPhone can show // them. Driven only by AppState changes (debounced), never by a timer: nothing // runs while no agent changes. Integrations (Stripe, Vercel…) are not sessions. // // In clear (needed for sorting and widgets): pillId, state, step counts, flags, // dates. Encrypted with the user's iCloud keys: project name, steps, cwd, // last message, the pending command and question. @MainActor final class SessionPublisher { static let shared = SessionPublisher() private let container = CKContainer(identifier: "iCloud.fr.louisraille.Coucou") private var database: CKDatabase { container.privateCloudDatabase } private var cancellable: AnyCancellable? /// What was last written to iCloud, by pill ID. private var published: [String: SessionSnapshot] = [:] private var zoneReady = false private var cleanedUp = false private var publishing = false private var pending: [String: SessionSnapshot]? /// Stops publishing and deletes this Mac's sessions from iCloud. func stop() { cancellable = nil pending = nil Task { do { let existing = try await existingSessions() let ids = existing.keys.map { SessionSnapshot.recordID(for: $0) } if !ids.isEmpty { _ = try await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) } log("publisher off, removed \(ids.count) session(s) from iCloud") } catch let error as CKError where error.code == .zoneNotFound || error.code == .userDeletedZone { log("publisher off, nothing in iCloud") } catch { log("publisher off, cleanup failed: \(error.localizedDescription)") } published = [:] cleanedUp = false } } func start() { guard cancellable == nil else { return } let state = AppState.shared cancellable = state.$tasks .combineLatest(state.$pendingApproval, state.$pendingQuestion) .map { tasks, approval, question in SessionSnapshot.all(tasks: tasks, approval: approval, question: question) } .removeDuplicates() .debounce(for: .milliseconds(500), scheduler: RunLoop.main) .sink { [weak self] snapshots in MainActor.assumeIsolated { self?.publish(snapshots) } } log("session publisher on") } // MARK: Publishing private func publish(_ snapshots: [String: SessionSnapshot]) { guard !publishing else { pending = snapshots; return } publishing = true Task { await write(snapshots) publishing = false if let next = pending { pending = nil publish(next) } } } private func write(_ snapshots: [String: SessionSnapshot]) async { do { if !zoneReady { _ = try await database.modifyRecordZones(saving: [CKRecordZone(zoneID: SessionSnapshot.zoneID)], deleting: []) zoneReady = true } if !cleanedUp { // Sessions left by a previous run (the Mac quit or crashed). published = try await existingSessions() cleanedUp = true } let changed = snapshots.values.filter { published[$0.pillId] != $0 } let removed = published.keys.filter { snapshots[$0] == nil } guard !changed.isEmpty || !removed.isEmpty else { return } let records = changed.map { $0.record() } let deletions = removed.map { SessionSnapshot.recordID(for: $0) } let result = try await database.modifyRecords(saving: records, deleting: deletions, savePolicy: .changedKeys, atomically: false) for (id, outcome) in result.saveResults { guard let pillId = SessionSnapshot.pillId(from: id) else { continue } switch outcome { case .success: published[pillId] = snapshots[pillId] case .failure(let error): log("save \(pillId) failed: \(error.localizedDescription)") } } for (id, outcome) in result.deleteResults { guard let pillId = SessionSnapshot.pillId(from: id) else { continue } switch outcome { case .success: published[pillId] = nil case .failure(let error): log("delete \(pillId) failed: \(error.localizedDescription)") } } let summary = changed.map { "\($0.pillId)=\($0.state)" }.sorted().joined(separator: ", ") log("published \(changed.count) session(s)\(summary.isEmpty ? "" : " [\(summary)]"), removed \(removed.count)") } catch let error as CKError where error.code == .zoneNotFound || error.code == .userDeletedZone { zoneReady = false log("zone missing, will recreate on the next change") } catch { log("publish failed: \(error.localizedDescription)") } } /// Session records already in the zone, so the first write can delete stale ones. private func existingSessions() async throws -> [String: SessionSnapshot] { var found: [String: SessionSnapshot] = [:] var token: CKServerChangeToken? var more = true while more { let changes = try await database.recordZoneChanges(inZoneWith: CloudProbe.zoneID, since: token) for (id, result) in changes.modificationResultsByID { guard case .success(let mod) = result, mod.record.recordType == SessionSnapshot.recordType, let pillId = SessionSnapshot.pillId(from: id) else { continue } // Unknown content: a placeholder that never equals a real snapshot, // so it is rewritten if the session still exists, deleted otherwise. found[pillId] = SessionSnapshot.placeholder(pillId: pillId) } token = changes.changeToken more = changes.moreComing } return found } private func log(_ message: String) { CloudProbe.shared.log("[sessions] \(message)") } } // MARK: - Snapshot /// The part of an AgentTask the iPhone needs. Equatable so unchanged sessions /// are not rewritten. struct SessionSnapshot: Equatable { static let recordType = "Session" let pillId: String let name: String let color: String let state: String let stepIndex: Int let steps: [String] let cwd: String let finalLine: String let approvalTool: String let approvalCommand: String let approvalFingerprint: String let question: String /// The question's choices (QuestionPayload JSON) and its fingerprint, for answering from the iPhone. var questionPayload: String = "" var questionFingerprint: String = "" static func all(tasks: [AgentTask], approval: ApprovalInfo?, question: AskQuestion?) -> [String: SessionSnapshot] { var result: [String: SessionSnapshot] = [:] // Services (Stripe, GitHub…) go through ServicePublisher, with their data. for task in tasks where task.source != .n8n && PillCatalog.isSession(task.id) { let hasApproval = approval?.pillId == task.id let questionText = task.state == .question ? (question?.questions.map(\.question).joined(separator: "\n") ?? "") : "" let payload = task.state == .question ? question.map(QuestionPayload.init(ask:)) : nil result[task.id] = SessionSnapshot( pillId: task.id, name: task.name, color: task.color, state: task.state.rawValue, stepIndex: task.stepIndex, steps: task.steps, cwd: task.sessionCwd ?? "", finalLine: task.finalLine ?? "", approvalTool: hasApproval ? (approval?.tool ?? "") : "", approvalCommand: hasApproval ? (approval?.command ?? "") : "", approvalFingerprint: hasApproval ? (approval.map(ApprovalRelay.fingerprint) ?? "") : "", question: questionText, questionPayload: payload?.json ?? "", questionFingerprint: payload?.fingerprint ?? "") } return result } static func placeholder(pillId: String) -> SessionSnapshot { SessionSnapshot(pillId: pillId, name: "", color: "", state: "", stepIndex: -1, steps: [], cwd: "", finalLine: "", approvalTool: "", approvalCommand: "", approvalFingerprint: "", question: "") } /// Same zone as CloudProbe.zoneID, rebuilt here because that one is main-actor isolated. static var zoneID: CKRecordZone.ID { CKRecordZone.ID(zoneName: "Coucou", ownerName: CKCurrentUserDefaultName) } static func recordID(for pillId: String) -> CKRecord.ID { CKRecord.ID(recordName: "session-\(pillId)", zoneID: zoneID) } static func pillId(from id: CKRecord.ID) -> String? { let name = id.recordName guard name.hasPrefix("session-") else { return nil } return String(name.dropFirst("session-".count)) } func record() -> CKRecord { let record = CKRecord(recordType: Self.recordType, recordID: Self.recordID(for: pillId)) record["pillId"] = pillId record["color"] = color record["state"] = state record["stepIndex"] = stepIndex record["stepCount"] = steps.count record["needsApproval"] = !approvalCommand.isEmpty || !approvalTool.isEmpty // Identifies the exact request; the iPhone sends it back with its decision. record["approvalFingerprint"] = approvalFingerprint record["needsAnswer"] = !question.isEmpty record["questionFingerprint"] = questionFingerprint record["updatedAt"] = Date() record["macName"] = Host.current().localizedName ?? "" // Whether this Mac runs instructions sent from the iPhone (GitHub build, switch on). #if APPSTORE record["acceptsInstructions"] = false #else record["acceptsInstructions"] = InstructionRunner.isEnabled && (pillId == "integration_claude" || pillId == "agent_cursor") #endif record.encryptedValues["name"] = name record.encryptedValues["steps"] = steps record.encryptedValues["cwd"] = cwd record.encryptedValues["finalLine"] = finalLine record.encryptedValues["approvalTool"] = approvalTool record.encryptedValues["approvalCommand"] = approvalCommand record.encryptedValues["question"] = question record.encryptedValues["questionPayload"] = questionPayload return record } } #endif