53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
11 KiB
Swift
258 lines
11 KiB
Swift
#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
|