From 8c1176c2d375ea7da248afb8bd3417dae9af2873 Mon Sep 17 00:00:00 2001 From: Oleksandr B Date: Tue, 8 Sep 2026 15:04:58 +0300 Subject: [PATCH] Update Live Activity agent counts from hook pushes in the background --- .../AgentActivityLedger.swift | 205 ++++++++++++++++++ .../AgentActivityLedgerTests.swift | 135 ++++++++++++ .../LiveActivityAgentUpdater.swift | 71 ++++++ .../NotificationService.swift | 64 +++++- SessionActivityWidget/AgentCountsViews.swift | 13 +- .../SessionActivityLockScreenView.swift | 2 +- .../SessionActivityWidget.swift | 6 +- rootshell.xcodeproj/project.pbxproj | 8 + .../AgentAttention/AgentAttentionCenter.swift | 42 +++- .../AgentAttention/AgentAttentionModels.swift | 49 ++++- .../LiveActivity/LiveActivityManager.swift | 76 ++++++- .../SessionActivityAttributes.swift | 18 +- .../System/LiveActivitySettingsView.swift | 2 +- 13 files changed, 664 insertions(+), 27 deletions(-) create mode 100644 Packages/RootshellPushKit/Sources/RootshellPushKit/AgentActivityLedger.swift create mode 100644 Packages/RootshellPushKit/Tests/RootshellPushKitTests/AgentActivityLedgerTests.swift create mode 100644 PushNotificationService/LiveActivityAgentUpdater.swift diff --git a/Packages/RootshellPushKit/Sources/RootshellPushKit/AgentActivityLedger.swift b/Packages/RootshellPushKit/Sources/RootshellPushKit/AgentActivityLedger.swift new file mode 100644 index 000000000..3579f5f11 --- /dev/null +++ b/Packages/RootshellPushKit/Sources/RootshellPushKit/AgentActivityLedger.swift @@ -0,0 +1,205 @@ +// +// AgentActivityLedger.swift +// RootshellPushKit +// +// App-group snapshot of the coding agents shown on the session Live +// Activity. The app writes it on the background edge, when on-device +// detection stops; the notification service extension applies agent hook +// pushes (rootshell-notify: blocked / done / failed) to it and republishes +// the Live Activity counts; the app deletes it on the next foreground +// reconcile, when live detection takes over again. +// + +import Foundation + +/// One detected coding agent and the push-route keys that identify its pane. +/// The keys mirror the three tiers of `PushNotificationRouter.resolve` so the +/// extension can match a `PushRoute` without the live tab registry. +public struct AgentActivityLedgerEntry: Codable, Sendable, Equatable { + public enum Bucket: String, Codable, Sendable { + case working + case attention + case idle + } + + /// The pane's own `TerminalView.uuid`. Identity only, never matched. + public var paneID: String + public var bucket: Bucket + /// `PushRoute.pane` for an ordinary terminal (its own UUID). Nil for a + /// tmux control-mode pane, whose route carries the gateway instead. + public var routePane: String? + /// tmux control-mode pane: the gateway terminal's UUID, which a + /// pre-canonical rootshell-notify puts in `PushRoute.pane`. + public var gatewayPane: String? + /// tmux control-mode pane: the canonical server identity, matched against + /// `PushRoute.tmuxServer`. Nil while the controller has not resolved it. + public var tmuxServer: String? + /// tmux control-mode pane: the server-global numeric pane id (`%12` -> 12). + public var tmuxPaneID: Int? + + public init(paneID: String, bucket: Bucket, routePane: String? = nil, gatewayPane: String? = nil, + tmuxServer: String? = nil, tmuxPaneID: Int? = nil) { + self.paneID = paneID + self.bucket = bucket + self.routePane = routePane + self.gatewayPane = gatewayPane + self.tmuxServer = tmuxServer + self.tmuxPaneID = tmuxPaneID + } +} + +public struct AgentActivityLedger: Codable, Sendable, Equatable { + /// `Activity.id` of the Live Activity the snapshot was taken for. The + /// extension updates that activity only; a snapshot left behind by an + /// activity that has since ended or been replaced matches nothing. + public var activityID: String + /// When the app wrote the snapshot (its background edge). + public var writtenAt: Date + /// Last time the extension applied a push to it; nil until the first one. + public var pushUpdatedAt: Date? + public var entries: [AgentActivityLedgerEntry] + + public init(activityID: String, writtenAt: Date = Date(), pushUpdatedAt: Date? = nil, + entries: [AgentActivityLedgerEntry]) { + self.activityID = activityID + self.writtenAt = writtenAt + self.pushUpdatedAt = pushUpdatedAt + self.entries = entries + } + + public var workingCount: Int { entries.filter { $0.bucket == .working }.count } + public var attentionCount: Int { entries.filter { $0.bucket == .attention }.count } + public var idleCount: Int { entries.filter { $0.bucket == .idle }.count } + + /// Hook statuses that move an agent into the attention bucket. The hooks + /// never report a return to work, so nothing here can move an agent out. + public static func bucket(forPushStatus status: String?) -> AgentActivityLedgerEntry.Bucket? { + switch status { + case "blocked", "done", "failed": return .attention + default: return nil + } + } + + /// Index of the single entry a route identifies, following the same tiers + /// as the app's resolver: canonical tmux server + pane id first, then the + /// legacy gateway UUID + pane id, then the plain pane UUID. Ambiguity and + /// no match both return nil; a wrong pane is worse than a stale count. + public func matchIndex(for route: PushRoute?) -> Int? { + guard let route else { return nil } + let tmuxPaneID = route.tmuxPane.flatMap { $0.hasPrefix("%") ? Int($0.dropFirst()) : nil } + + // UUID strings: the hook forwards whatever the app exported, the app + // parses them case-insensitively, so compare the same way here. + func sameUUID(_ a: String?, _ b: String) -> Bool { + guard let a else { return false } + return a.caseInsensitiveCompare(b) == .orderedSame + } + + var canonical: [Int] = [] + var legacy: [Int] = [] + var plain: [Int] = [] + for (index, entry) in entries.enumerated() { + if let tmuxPaneID, let server = route.tmuxServer, + entry.tmuxPaneID == tmuxPaneID, entry.tmuxServer == server { + canonical.append(index) + } else if route.tmuxServer == nil, let tmuxPaneID, let pane = route.pane, + entry.tmuxPaneID == tmuxPaneID, sameUUID(entry.gatewayPane, pane) { + legacy.append(index) + } else if let pane = route.pane, entry.tmuxPaneID == nil, sameUUID(entry.routePane, pane) { + plain.append(index) + } + } + let matches = !canonical.isEmpty ? canonical : (!legacy.isEmpty ? legacy : plain) + return matches.count == 1 ? matches[0] : nil + } + + /// Applies one hook push. Returns true when a count changed; a repeat of + /// an already-applied push returns false and leaves `pushUpdatedAt` alone. + @discardableResult + public mutating func apply(status: String?, route: PushRoute?, at date: Date = Date()) -> Bool { + guard let bucket = Self.bucket(forPushStatus: status), + let index = matchIndex(for: route) else { return false } + guard entries[index].bucket != bucket else { return false } + entries[index].bucket = bucket + pushUpdatedAt = date + return true + } +} + +/// App-group file behind `AgentActivityLedger`. Same discipline as +/// `PushSharedState`: atomic writes, failures swallowed, no +/// `NSFileCoordinator` (it can block the extension while the app is +/// suspended). Read-modify-write goes through a short `flock` on a sidecar so +/// two extension instances, or the app and the extension, cannot drop each +/// other's transition; a holder that never releases only costs the waiter a +/// quarter second, after which it proceeds unlocked (last writer wins). +public struct AgentActivityLedgerStore: Sendable { + /// A snapshot older than this is ignored: the app that wrote it is gone + /// and so, normally, is its Live Activity. + public static let maxAge: TimeInterval = 24 * 3600 + + let fileURL: URL? + let lockURL: URL? + + public init(appGroup: String = PushConfiguration.appGroup) { + self.init(container: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)) + } + + public init(container: URL?) { + fileURL = container?.appendingPathComponent("live-activity-agents.json") + lockURL = container?.appendingPathComponent("live-activity-agents.lock") + } + + public func load(now: Date = Date()) -> AgentActivityLedger? { + withLock { loadUnlocked(now: now) } + } + + public func save(_ ledger: AgentActivityLedger) { + withLock { write(ledger) } + } + + public func clear() { + withLock { + guard let fileURL else { return } + try? FileManager.default.removeItem(at: fileURL) + } + } + + /// Atomic read-modify-write. `transform` returns whether it changed the + /// ledger; the file is rewritten only then. Returns the ledger as it + /// stands after the call, or nil when there is no usable snapshot. + public func modify(now: Date = Date(), _ transform: (inout AgentActivityLedger) -> Bool) -> AgentActivityLedger? { + withLock { + guard var ledger = loadUnlocked(now: now) else { return nil } + if transform(&ledger) { write(ledger) } + return ledger + } + } + + private func loadUnlocked(now: Date) -> AgentActivityLedger? { + guard let fileURL, let data = try? Data(contentsOf: fileURL), + let ledger = try? JSONDecoder().decode(AgentActivityLedger.self, from: data) else { return nil } + guard now.timeIntervalSince(ledger.writtenAt) < Self.maxAge else { return nil } + return ledger + } + + private func write(_ ledger: AgentActivityLedger) { + guard let fileURL, let data = try? JSONEncoder().encode(ledger) else { return } + try? data.write(to: fileURL, options: .atomic) + } + + private func withLock(_ body: () -> T) -> T { + guard let lockURL else { return body() } + let fd = open(lockURL.path, O_CREAT | O_RDWR, 0o600) + guard fd >= 0 else { return body() } + defer { close(fd) } + var waitedMicros: UInt32 = 0 + while flock(fd, LOCK_EX | LOCK_NB) != 0 { + guard waitedMicros < 250_000 else { break } + usleep(10_000) + waitedMicros += 10_000 + } + defer { flock(fd, LOCK_UN) } + return body() + } +} diff --git a/Packages/RootshellPushKit/Tests/RootshellPushKitTests/AgentActivityLedgerTests.swift b/Packages/RootshellPushKit/Tests/RootshellPushKitTests/AgentActivityLedgerTests.swift new file mode 100644 index 000000000..3fac72cb9 --- /dev/null +++ b/Packages/RootshellPushKit/Tests/RootshellPushKitTests/AgentActivityLedgerTests.swift @@ -0,0 +1,135 @@ +import Foundation +import Testing +@testable import RootshellPushKit + +@Suite("Live Activity agent ledger") +struct AgentActivityLedgerTests { + private let plainPane = "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D" + private let gateway = "B2C3D4E5-F6A7-4B8C-9D0E-1F2A3B4C5D6E" + private let childPane = "C3D4E5F6-A7B8-4C9D-0E1F-2A3B4C5D6E7F" + private let unresolvedChild = "D4E5F6A7-B8C9-4D0E-1F2A-3B4C5D6E7F8A" + private let server = "host:/tmp/tmux-501/default,4242,1725700000" + private let activityID = "activity-1" + + private func ledger() -> AgentActivityLedger { + AgentActivityLedger(activityID: activityID, entries: [ + AgentActivityLedgerEntry(paneID: plainPane, bucket: .working, routePane: plainPane), + AgentActivityLedgerEntry(paneID: childPane, bucket: .working, gatewayPane: gateway, + tmuxServer: server, tmuxPaneID: 7), + ]) + } + + @Test("Plain pane matches by its own UUID only") + func plainPaneMatch() { + let l = ledger() + #expect(l.matchIndex(for: PushRoute(pane: plainPane)) == 0) + #expect(l.matchIndex(for: PushRoute(pane: gateway)) == nil) + #expect(l.matchIndex(for: PushRoute(pane: childPane)) == nil) + } + + @Test("Control-mode pane matches canonical server + pane id") + func canonicalTmux() { + let l = ledger() + #expect(l.matchIndex(for: PushRoute(pane: gateway, tmuxPane: "%7", tmuxServer: server)) == 1) + #expect(l.matchIndex(for: PushRoute(pane: gateway, tmuxPane: "%8", tmuxServer: server)) == nil) + #expect(l.matchIndex(for: PushRoute(pane: gateway, tmuxPane: "%7", tmuxServer: "other")) == nil) + } + + @Test("Canonical route against an entry whose server is still unresolved falls to the gateway tier") + func unresolvedServer() { + var l = ledger() + l.entries[1].tmuxServer = nil + // With a server in the route nothing canonical matches and the legacy + // tier requires the route to carry no server, so no match. + #expect(l.matchIndex(for: PushRoute(pane: gateway, tmuxPane: "%7", tmuxServer: server)) == nil) + // A pre-canonical sender still resolves through the gateway UUID. + #expect(l.matchIndex(for: PushRoute(pane: gateway, tmuxPane: "%7")) == 1) + } + + @Test("Pre-canonical sender matches gateway UUID + pane id") + func legacyTmux() { + let l = ledger() + #expect(l.matchIndex(for: PushRoute(pane: gateway, tmuxPane: "%7")) == 1) + // Ordinary tmux inside a regular pane sets TMUX_PANE too; the pane's + // own UUID still identifies it, as in the app's resolver. + #expect(l.matchIndex(for: PushRoute(pane: plainPane, tmuxPane: "%7")) == 0) + #expect(l.matchIndex(for: PushRoute(pane: gateway, tmuxPane: "%9")) == nil) + } + + @Test("Ambiguous routes match nothing") + func ambiguity() { + var l = ledger() + l.entries.append(AgentActivityLedgerEntry(paneID: "dup", bucket: .idle, routePane: plainPane)) + #expect(l.matchIndex(for: PushRoute(pane: plainPane)) == nil) + #expect(l.matchIndex(for: nil) == nil) + + var c = ledger() + c.entries.append(AgentActivityLedgerEntry(paneID: unresolvedChild, bucket: .idle, gatewayPane: gateway, + tmuxServer: server, tmuxPaneID: 7)) + #expect(c.matchIndex(for: PushRoute(pane: gateway, tmuxPane: "%7", tmuxServer: server)) == nil) + } + + @Test("Lowercase UUIDs from the hook still match", arguments: [true, false]) + func caseInsensitiveUUID(withTmuxPane: Bool) { + let l = ledger() + if withTmuxPane { + #expect(l.matchIndex(for: PushRoute(pane: gateway.lowercased(), tmuxPane: "%7")) == 1) + } else { + #expect(l.matchIndex(for: PushRoute(pane: plainPane.lowercased())) == 0) + } + } + + @Test("Hook statuses move a pane to attention once", arguments: ["blocked", "done", "failed"]) + func applyStatus(status: String) { + var l = ledger() + let stamp = Date(timeIntervalSince1970: 1_725_700_000) + let first = l.apply(status: status, route: PushRoute(pane: plainPane), at: stamp) + #expect(first) + #expect(l.entries[0].bucket == .attention) + #expect(l.pushUpdatedAt == stamp) + #expect(l.workingCount == 1) + #expect(l.attentionCount == 1) + let second = l.apply(status: status, route: PushRoute(pane: plainPane), at: stamp.addingTimeInterval(60)) + #expect(!second) + #expect(l.pushUpdatedAt == stamp) + } + + @Test("Unknown statuses and unmatched routes change nothing") + func applyIgnored() { + var l = ledger() + let working = l.apply(status: "working", route: PushRoute(pane: plainPane)) + let missing = l.apply(status: nil, route: PushRoute(pane: plainPane)) + let unknownPane = l.apply(status: "blocked", route: PushRoute(pane: "not-a-known-pane")) + #expect(!working) + #expect(!missing) + #expect(!unknownPane) + #expect(l.entries == ledger().entries) + #expect(l.pushUpdatedAt == nil) + } + + @Test("Store round-trips, modifies in place and expires old snapshots") + func store() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let store = AgentActivityLedgerStore(container: dir) + + #expect(store.load() == nil) + #expect(store.modify { _ in true } == nil) + + let written = ledger() + store.save(written) + #expect(store.load() == written) + + let modified = store.modify { $0.apply(status: "blocked", route: PushRoute(pane: plainPane)) } + #expect(modified?.attentionCount == 1) + #expect(store.load()?.attentionCount == 1) + + let untouched = store.modify { _ in false } + #expect(untouched == store.load()) + + #expect(store.load(now: written.writtenAt.addingTimeInterval(AgentActivityLedgerStore.maxAge + 1)) == nil) + store.clear() + #expect(store.load() == nil) + } +} diff --git a/PushNotificationService/LiveActivityAgentUpdater.swift b/PushNotificationService/LiveActivityAgentUpdater.swift new file mode 100644 index 000000000..995b64d78 --- /dev/null +++ b/PushNotificationService/LiveActivityAgentUpdater.swift @@ -0,0 +1,71 @@ +// +// LiveActivityAgentUpdater.swift +// PushNotificationService +// +// Applies an agent hook push (rootshell-notify: blocked / done / failed) to +// the session Live Activity while the app is backgrounded. The app leaves +// its per-pane agent census in the app group on the background edge +// (`AgentActivityLedger`); a matching push moves that pane to "needs +// attention" and the counts are republished from here. No ledger means the +// app is in the foreground, which clears the file, or never showed agents. +// + +#if canImport(ActivityKit) && !targetEnvironment(macCatalyst) +import ActivityKit +import Foundation +import RootshellPushKit +import os + +// The extension's default isolation is the main actor; this runs on the +// system queue and on a detached task, so opt out like NotificationService. +nonisolated enum LiveActivityAgentUpdater { + private static let logger = Logger(subsystem: "com.rootshell", category: "PushNSE.liveActivity") + + /// Whether `apply` would do anything for this header, cheap enough to + /// decide before paying for the wait in the delivery path. + static func handles(_ header: PushHeader) -> Bool { + header.kind == "agent" && AgentActivityLedger.bucket(forPushStatus: header.status) != nil + } + + static func apply(header: PushHeader, eid: String) async { + guard handles(header) else { return } + let store = AgentActivityLedgerStore() + + // One locked read-modify-write; a repeat of an already-applied push + // still republishes, so a redelivery repairs an update the first + // delivery did not get to finish. + var matched = false + guard let ledger = store.modify({ ledger in + guard ledger.matchIndex(for: header.route) != nil else { return false } + matched = true + return ledger.apply(status: header.status, route: header.route) + }), matched else { + logger.info("push not in ledger eid=\(eid, privacy: .public)") + return + } + + // Only the activity the snapshot was taken for, and only while it is + // still frozen: an unfrozen state means the app is publishing from + // live detection and this snapshot is stale. + guard let activity = Activity.activities.first(where: { $0.id == ledger.activityID }) else { + logger.info("ledger activity gone eid=\(eid, privacy: .public); clearing ledger") + store.clear() + return + } + var state = activity.content.state + guard state.agentCountsFrozen else { + logger.info("activity not frozen eid=\(eid, privacy: .public); app owns the counts") + return + } + state.agentWorkingCount = ledger.workingCount + state.agentAttentionCount = ledger.attentionCount + state.agentIdleCount = ledger.idleCount + state.agentPushUpdatedAt = ledger.pushUpdatedAt ?? state.agentPushUpdatedAt + await activity.update( + ActivityContent(state: state, staleDate: nil), + alertConfiguration: nil, + timestamp: Date()) + logger.info("live activity updated eid=\(eid, privacy: .public): attention=\(ledger.attentionCount) working=\(ledger.workingCount) idle=\(ledger.idleCount)") + } +} +#endif diff --git a/PushNotificationService/NotificationService.swift b/PushNotificationService/NotificationService.swift index f4d2765dc..65a97b95c 100644 --- a/PushNotificationService/NotificationService.swift +++ b/PushNotificationService/NotificationService.swift @@ -12,10 +12,15 @@ import RootshellPushKit import UserNotifications import os -// Callbacks arrive on a system queue, never the main thread. -nonisolated final class NotificationService: UNNotificationServiceExtension { +// Callbacks arrive on a system queue, never the main thread. The Live +// Activity path hands over from a detached task and a timer; every shared +// field they touch goes through `finishLock`, hence the unchecked Sendable. +nonisolated final class NotificationService: UNNotificationServiceExtension, @unchecked Sendable { private static let logger = Logger(subsystem: "com.rootshell", category: "PushNSE") + /// Guards the one-shot hand-over: the Live Activity path, its cap and + /// `serviceExtensionTimeWillExpire` can all race to `finish`. + private let finishLock = NSLock() private var contentHandler: ((UNNotificationContent) -> Void)? private var content: UNMutableNotificationContent? /// Set once the decrypted header has been applied to `content`. @@ -35,7 +40,25 @@ nonisolated final class NotificationService: UNNotificationServiceExtension { } eid = envelope.eid do { - try decorate(content, envelope: envelope) + let header = try decorate(content, envelope: envelope) + #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) + if let header, LiveActivityAgentUpdater.handles(header) { + // Fold the push into the Live Activity before the banner is + // handed over: the process may be suspended right after. The + // callback queue is not blocked, and `finish` is one-shot, so + // whichever of the update or the cap comes first delivers. + let eid = envelope.eid + Task.detached(priority: .userInitiated) { [weak self] in + await LiveActivityAgentUpdater.apply(header: header, eid: eid) + self?.finishDecrypted() + } + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + Self.liveActivityUpdateCap) { [weak self] in + guard let self, self.finishDecrypted() else { return } + Self.logger.error("live activity update timed out eid=\(eid, privacy: .public); banner delivered") + } + return + } + #endif finish(content) } catch { Self.logger.error("push decrypt failed eid=\(self.eid, privacy: .public): \(String(describing: error), privacy: .public)") @@ -54,10 +77,31 @@ nonisolated final class NotificationService: UNNotificationServiceExtension { } } - private func finish(_ content: UNNotificationContent) { - guard let handler = contentHandler else { return } + /// Longest the banner waits for the Live Activity update. + private static let liveActivityUpdateCap: DispatchTimeInterval = .seconds(2) + + /// Hands the content over exactly once. Returns true for the call that + /// actually delivered. + @discardableResult + private func finish(_ content: UNNotificationContent) -> Bool { + finishLock.lock() + let handler = contentHandler contentHandler = nil + finishLock.unlock() + guard let handler else { return false } handler(content) + return true + } + + /// `finish` with the already-decorated content, for the callers that run + /// off the callback queue and must not capture it. + @discardableResult + private func finishDecrypted() -> Bool { + finishLock.lock() + let content = decrypted + finishLock.unlock() + guard let content else { return false } + return finish(content) } private func fallback(_ content: UNMutableNotificationContent) -> UNNotificationContent { @@ -84,12 +128,15 @@ nonisolated final class NotificationService: UNNotificationServiceExtension { return content } - private func decorate(_ content: UNMutableNotificationContent, envelope: PushEnvelope) throws { + /// Returns the decrypted header, or nil when the push was silenced and + /// already handed over. + @discardableResult + private func decorate(_ content: UNMutableNotificationContent, envelope: PushEnvelope) throws -> PushHeader? { let shared = PushSharedState() let policy = shared.loadPolicy() guard policy.accepts(envelope) else { finish(silence(content, reason: "policy")) - return + return nil } guard let key = try PushConfiguration.keychain.loadPrivateKey() else { throw PushCryptoError.noPrivateKey } let header = try envelope.open(with: key) @@ -104,7 +151,9 @@ nonisolated final class NotificationService: UNNotificationServiceExtension { var info = content.userInfo info[PushConfiguration.headerUserInfoKey] = try header.userInfoDictionary() content.userInfo = info + finishLock.lock() decrypted = content + finishLock.unlock() // APNs uses eid as the collapse id, so a relay retry updates the same // notification. Keep the claim as one-shot ledger bookkeeping, but @@ -122,5 +171,6 @@ nonisolated final class NotificationService: UNNotificationServiceExtension { Self.logger.info("logo skipped eid=\(self.eid, privacy: .public): agent \(header.agent ?? "-", privacy: .public)") } } + return header } } diff --git a/SessionActivityWidget/AgentCountsViews.swift b/SessionActivityWidget/AgentCountsViews.swift index 15555519d..e302e4280 100644 --- a/SessionActivityWidget/AgentCountsViews.swift +++ b/SessionActivityWidget/AgentCountsViews.swift @@ -39,6 +39,11 @@ enum AgentCountsText { static var updatesPaused: String { String(localized: "Updates paused") } + + /// Time of the last agent hook push applied while detection was paused. + static func updatedAt(_ date: Date) -> String { + String(localized: "Updated \(date.formatted(date: .omitted, time: .shortened))") + } } /// "1 needs attention · 2 working · 1 idle", most urgent first, zero buckets @@ -62,14 +67,16 @@ struct AgentSummaryLine: View { if state.agentIdleCount > 0 { parts.append(AgentCountsText.idle(state.agentIdleCount)) } - if state.agentCountsFrozen { + if let pushedAt = state.agentPushUpdatedAt, state.agentCountsFrozen { + parts.append(AgentCountsText.updatedAt(pushedAt)) + } else if state.agentCountsFrozen { parts.append(AgentCountsText.updatesPaused) } return parts } private var dotStyle: AnyShapeStyle { - if state.agentCountsFrozen { return mutedStyle } + if state.agentCountsMuted { return mutedStyle } if state.agentAttentionCount > 0 { return AnyShapeStyle(.orange) } if state.agentWorkingCount > 0 { return AnyShapeStyle(.green) } return mutedStyle @@ -82,7 +89,7 @@ struct AgentSummaryLine: View { .frame(width: 6, height: 6) Text(segments.joined(separator: " \u{00B7} ")) .font(font) - .foregroundStyle(state.agentCountsFrozen ? mutedStyle : AnyShapeStyle(.primary)) + .foregroundStyle(state.agentCountsMuted ? mutedStyle : AnyShapeStyle(.primary)) .lineLimit(1) .minimumScaleFactor(0.8) .truncationMode(.tail) diff --git a/SessionActivityWidget/SessionActivityLockScreenView.swift b/SessionActivityWidget/SessionActivityLockScreenView.swift index 8c8ae4aa0..4e02c18cc 100644 --- a/SessionActivityWidget/SessionActivityLockScreenView.swift +++ b/SessionActivityWidget/SessionActivityLockScreenView.swift @@ -139,7 +139,7 @@ struct SessionActivityLockScreenView: View { if hasAgents { Label(AgentCountsText.agents(state.agentTotalCount), systemImage: "sparkles") .font(.caption2) - .foregroundStyle(state.agentCountsFrozen ? mutedAgentStyle : agentAccentStyle) + .foregroundStyle(state.agentCountsMuted ? mutedAgentStyle : agentAccentStyle) } Spacer() diff --git a/SessionActivityWidget/SessionActivityWidget.swift b/SessionActivityWidget/SessionActivityWidget.swift index 04646216f..46ae6667a 100644 --- a/SessionActivityWidget/SessionActivityWidget.swift +++ b/SessionActivityWidget/SessionActivityWidget.swift @@ -156,7 +156,7 @@ struct SessionActivityWidget: Widget { if context.state.agentTotalCount > 0 { Label("\(context.state.agentTotalCount)", systemImage: "sparkles") .font(.caption2) - .foregroundStyle(context.state.agentCountsFrozen ? AnyShapeStyle(.secondary) : AnyShapeStyle(.mint)) + .foregroundStyle(context.state.agentCountsMuted ? AnyShapeStyle(.secondary) : AnyShapeStyle(.mint)) } } } @@ -208,10 +208,10 @@ struct SessionActivityWidget: Widget { if context.state.agentAttentionCount > 0 { Image(systemName: "exclamationmark.bubble.fill") .font(.caption2) - .foregroundStyle(context.state.agentCountsFrozen ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange)) + .foregroundStyle(context.state.agentCountsMuted ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange)) Text("\(context.state.agentAttentionCount)") .font(.caption) - .foregroundStyle(context.state.agentCountsFrozen ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange)) + .foregroundStyle(context.state.agentCountsMuted ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange)) } if context.state.wifiSSID != nil { Image(systemName: "wifi") diff --git a/rootshell.xcodeproj/project.pbxproj b/rootshell.xcodeproj/project.pbxproj index 585ad7ed0..fac2e7510 100644 --- a/rootshell.xcodeproj/project.pbxproj +++ b/rootshell.xcodeproj/project.pbxproj @@ -619,6 +619,13 @@ ); target = 47PSH0012F900000AABB0001 /* PushNotificationService */; }; + 47PSH0182F900000AABB0018 /* Exceptions for "rootshell" folder in "PushNotificationService" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Features/LiveActivity/SessionActivityAttributes.swift, + ); + target = 47PSH0012F900000AABB0001 /* PushNotificationService */; + }; 47VPN0042F460000BBCC0004 /* Exceptions for "rootshell" folder in "VPNTunnelExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -757,6 +764,7 @@ exceptions = ( 4771ED432ECBE569009B57F8 /* Exceptions for "rootshell" folder in "rootshell" target */, 47LA00162F360000AABB0016 /* Exceptions for "rootshell" folder in "SessionActivityWidgetExtension" target */, + 47PSH0182F900000AABB0018 /* Exceptions for "rootshell" folder in "PushNotificationService" target */, 47VPN0042F460000BBCC0004 /* Exceptions for "rootshell" folder in "VPNTunnelExtension" target */, 47CD00009900000000000000 /* Exceptions for "rootshell" folder in "rootshell-china" target */, 47VPNT0100000000000000D1 /* Exceptions for "rootshell" folder in "rootshellvpn" target */, diff --git a/rootshell/Features/AgentAttention/AgentAttentionCenter.swift b/rootshell/Features/AgentAttention/AgentAttentionCenter.swift index dea778bd7..8168ce963 100644 --- a/rootshell/Features/AgentAttention/AgentAttentionCenter.swift +++ b/rootshell/Features/AgentAttention/AgentAttentionCenter.swift @@ -1346,19 +1346,53 @@ final class AgentAttentionCenter { /// progress overlay counts the way the sidebar shows it. One entry per /// pane; Claude fleet sub-agents ride along with their session. func codingAgentCounts() -> CodingAgentCounts { - var counts = CodingAgentCounts() + codingAgentCensus().counts + } + + /// The census with one entry per counted pane, carrying the push-route + /// keys the notification service extension matches hook pushes against + /// while the app is backgrounded (see `AgentActivityLedger`). + func codingAgentCensus() -> CodingAgentCensus { + var census = CodingAgentCensus() for model in TmuxWindowRegistry.allTabsModels() { for tab in model.tabs { for pane in tab.splitTree { guard let detected = pane.presentation.detectedAgentRow, detected.category == .agent, - let row = pane.presentation.agentRow + let row = pane.presentation.agentRow, + let bucket = CodingAgentBucket(row.status) else { continue } - counts.add(row.status) + census.counts.add(bucket) + census.entries.append(Self.codingAgentEntry(for: pane, bucket: bucket)) } } } - return counts + return census + } + + /// Route keys mirror `PushNotificationRouter.resolve`: an ordinary pane is + /// its own UUID; a tmux control-mode pane is the canonical server identity + /// plus the server-global pane id, with the gateway UUID kept for + /// pre-canonical senders. The server identity is taken only from the + /// controller that really owns the gateway (see the ABA note on + /// `TmuxPaneBinding.parentUUID`). + private static func codingAgentEntry(for pane: SplitPaneView, bucket: CodingAgentBucket) -> CodingAgentEntry { + guard let view = pane as? Ghostty.TerminalView else { + return CodingAgentEntry(paneID: pane.uuid, bucket: bucket) + } + if let binding = view.tmuxPaneBinding { + let controller = TmuxController.controller(forOwnerSurface: binding.parentSurface) + let server = controller?.ownerTerminalUUIDForNotifications == binding.parentUUID + ? controller?.pushRouteServerIdentity + : nil + return CodingAgentEntry( + paneID: view.uuid, bucket: bucket, gatewayPane: binding.parentUUID, + tmuxServer: server, tmuxPaneID: binding.paneId) + } + // A control-mode gateway is never addressed by its UUID (the resolver + // skips it too); its agents live in the bound display panes above. + let routePane = view.tmuxController?.isActive == true ? nil : view.uuid + return CodingAgentEntry(paneID: view.uuid, bucket: bucket, routePane: routePane) } /// Live agent providers and the terminal that OWNS each one's connection diff --git a/rootshell/Features/AgentAttention/AgentAttentionModels.swift b/rootshell/Features/AgentAttention/AgentAttentionModels.swift index d6f8beaf1..bf6c2295e 100644 --- a/rootshell/Features/AgentAttention/AgentAttentionModels.swift +++ b/rootshell/Features/AgentAttention/AgentAttentionModels.swift @@ -66,16 +66,57 @@ nonisolated struct CodingAgentCounts: Equatable, Sendable { var total: Int { working + attention + idle } + mutating func add(_ bucket: CodingAgentBucket) { + switch bucket { + case .working: working += 1 + case .attention: attention += 1 + case .idle: idle += 1 + } + } + mutating func add(_ status: AgentAttentionStatus) { + if let bucket = CodingAgentBucket(status) { add(bucket) } + } +} + +/// The three Live Activity buckets. `unknown` maps to none and is not counted. +nonisolated enum CodingAgentBucket: String, Equatable, Sendable { + case working + case attention + case idle + + init?(_ status: AgentAttentionStatus) { switch status { - case .working: working += 1 - case .blocked, .failed, .done: attention += 1 - case .idle, .paused: idle += 1 - case .unknown: break + case .working: self = .working + case .blocked, .failed, .done: self = .attention + case .idle, .paused: self = .idle + case .unknown: return nil } } } +/// One counted agent with the push-route keys that identify its pane, so the +/// notification service extension can apply a hook push to it while the app +/// is backgrounded. Keys mirror `PushNotificationRouter.resolve`. +nonisolated struct CodingAgentEntry: Equatable, Sendable { + /// The pane's own `TerminalView.uuid`. + let paneID: UUID + let bucket: CodingAgentBucket + /// Ordinary pane: what the hook sends as `pane` (the same UUID). + var routePane: UUID? = nil + /// tmux control-mode pane: the gateway terminal's UUID. + var gatewayPane: UUID? = nil + /// tmux control-mode pane: canonical server identity, nil until resolved. + var tmuxServer: String? = nil + /// tmux control-mode pane: server-global numeric pane id. + var tmuxPaneID: Int? = nil +} + +nonisolated struct CodingAgentCensus: Equatable, Sendable { + var counts = CodingAgentCounts() + var entries: [CodingAgentEntry] = [] +} + // MARK: - Notification event identity /// Stable identity for one semantic attention event. Repeated scans, diff --git a/rootshell/Features/LiveActivity/LiveActivityManager.swift b/rootshell/Features/LiveActivity/LiveActivityManager.swift index 30ca5b7f4..7b1a42d05 100644 --- a/rootshell/Features/LiveActivity/LiveActivityManager.swift +++ b/rootshell/Features/LiveActivity/LiveActivityManager.swift @@ -11,6 +11,7 @@ import ActivityKit import Foundation import Observation +import RootshellPushKit import os.log import UIKit @@ -177,6 +178,16 @@ class LiveActivityManager { private var lastAgentAttentionCount: Int = 0 @ObservationIgnored private var lastAgentIdleCount: Int = 0 + /// Per-pane census with push-route keys, written to the app group on the + /// background edge for the notification service extension. + @ObservationIgnored + private var lastAgentEntries: [CodingAgentEntry] = [] + @ObservationIgnored + private let agentLedgerStore = AgentActivityLedgerStore() + /// Serial, so a background-edge save and a foreground clear land in the + /// order they were issued even across a quick app switch. + @ObservationIgnored + private let agentLedgerQueue = DispatchQueue(label: "com.rootshell.liveActivity.agentLedger", qos: .utility) /// Coalesces bursts of census changes into one publish. @ObservationIgnored @@ -430,6 +441,9 @@ class LiveActivityManager { func reconcileAfterActivation() { let start = CFAbsoluteTimeGetCurrent() LifecycleDebugLogger.shared.checkpoint("LiveActivity.reconcile.enter") + // Live detection owns the counts again; anything the extension + // applied from pushes is superseded by the publish below. + clearAgentLedger() if isAgentInfoEnabled { refreshAgentCountsCache() } @@ -857,7 +871,11 @@ class LiveActivityManager { /// Snapshot the census into the cache. Returns true when a bucket changed. @discardableResult private func refreshAgentCountsCache() -> Bool { - let counts = AgentAttentionCenter.shared.codingAgentCounts() + let census = AgentAttentionCenter.shared.codingAgentCensus() + // Entries refresh even when no bucket moved: a tmux server identity + // can resolve later without changing any count. + lastAgentEntries = census.entries + let counts = census.counts guard counts.working != lastAgentWorkingCount || counts.attention != lastAgentAttentionCount || counts.idle != lastAgentIdleCount @@ -872,6 +890,35 @@ class LiveActivityManager { lastAgentWorkingCount = 0 lastAgentAttentionCount = 0 lastAgentIdleCount = 0 + lastAgentEntries = [] + } + + // MARK: - Agent ledger (notification service extension hand-off) + + /// Leaves the per-pane census in the app group when detection stops. An + /// agent hook push (blocked / done / failed) arriving while the app is + /// backgrounded lets the notification service extension move that pane + /// to "needs attention" and republish the counts; see + /// `AgentActivityLedger`. Cached entries only, no registry walk, and the + /// write happens off the main thread so the scene transaction is not + /// held. With nothing to hand off the file is removed so a stale + /// snapshot can never match a later push. + private func writeAgentLedgerForBackground() { + let store = agentLedgerStore + guard isEnabled, isAgentInfoEnabled, let activity = currentActivity, !lastAgentEntries.isEmpty else { + agentLedgerQueue.async { store.clear() } + return + } + let ledger = AgentActivityLedger(activityID: activity.id, entries: lastAgentEntries.map(\.ledgerEntry)) + agentLedgerQueue.async { store.save(ledger) } + } + + /// Every path that stops treating `currentActivity` as live must clear: + /// the extension only ever updates the activity id in the file, but a + /// stale file still costs a load and a lock per push. + private func clearAgentLedger() { + let store = agentLedgerStore + agentLedgerQueue.async { store.clear() } } private func scheduleAgentPublish() { @@ -920,6 +967,7 @@ class LiveActivityManager { reconcileActivityLifecycle(reason: "agent info enabled") } else { clearAgentCountsCache() + clearAgentLedger() // Ends an agent-only activity; a mixed one republishes without // the agent fields. reconcileActivityLifecycle(reason: "agent info disabled") @@ -1142,6 +1190,7 @@ class LiveActivityManager { activityStartDate = nil isActivityActive = false lastPublishedState = nil + clearAgentLedger() } // MARK: - Activity Lifecycle @@ -1246,6 +1295,7 @@ class LiveActivityManager { // re-drive the request instead of creating an unfrozen activity after // the one background-edge callback has already passed. cancelStartRetry(resetAttempts: true) + writeAgentLedgerForBackground() guard let activity = currentActivity, var state = lastPublishedState, state.agentTotalCount > 0, @@ -1257,6 +1307,7 @@ class LiveActivityManager { private func endActivity() { cancelStartRetry(resetAttempts: true) + clearAgentLedger() guard let activity = currentActivity else { return } @@ -1338,6 +1389,7 @@ class LiveActivityManager { self.userDismissed = true self.cancelAgentPublish() self.lastPublishedState = nil + self.clearAgentLedger() // Tear down WiFi/network machinery started alongside the activity self.stopWiFiPolling() @@ -1415,4 +1467,26 @@ class LiveActivityManager { } } } + +private extension CodingAgentEntry { + var ledgerEntry: AgentActivityLedgerEntry { + AgentActivityLedgerEntry( + paneID: paneID.uuidString, + bucket: bucket.ledgerBucket, + routePane: routePane?.uuidString, + gatewayPane: gatewayPane?.uuidString, + tmuxServer: tmuxServer, + tmuxPaneID: tmuxPaneID) + } +} + +private extension CodingAgentBucket { + var ledgerBucket: AgentActivityLedgerEntry.Bucket { + switch self { + case .working: return .working + case .attention: return .attention + case .idle: return .idle + } + } +} #endif diff --git a/rootshell/Features/LiveActivity/SessionActivityAttributes.swift b/rootshell/Features/LiveActivity/SessionActivityAttributes.swift index 10dd3b1b3..1f3b8cfb5 100644 --- a/rootshell/Features/LiveActivity/SessionActivityAttributes.swift +++ b/rootshell/Features/LiveActivity/SessionActivityAttributes.swift @@ -10,9 +10,11 @@ import ActivityKit import Foundation -struct SessionActivityAttributes: ActivityAttributes { +// Plain data shared by the app, the widget and the notification service +// extension, which reads and updates it off the main actor. +nonisolated struct SessionActivityAttributes: ActivityAttributes { /// Static context — empty since all data is dynamic - struct ContentState: Codable, Hashable { + nonisolated struct ContentState: Codable, Hashable { /// Total non-resilient session count var sessionCount: Int @@ -78,9 +80,18 @@ struct SessionActivityAttributes: ActivityAttributes { /// state and must leave it untouched. var agentCountsFrozen: Bool = false + /// Set by the notification service extension when an agent hook push + /// moved a pane to "needs attention" while the counts were frozen. + /// Nil once the app publishes from live detection again. + var agentPushUpdatedAt: Date? = nil + /// Total detected coding-agent sessions. var agentTotalCount: Int { agentWorkingCount + agentAttentionCount + agentIdleCount } + /// Frozen counts nobody has refreshed render muted; counts a push has + /// touched are current for what matters and keep their colors. + var agentCountsMuted: Bool { agentCountsFrozen && agentPushUpdatedAt == nil } + // MARK: - App Icon /// User-selected app icon variant (raw value of `AppIconVariant`). @@ -132,7 +143,7 @@ extension SessionActivityAttributes.ContentState { /// /// When adding a field: give it a default in the struct AND decode it /// here with `decodeIfPresent`. - init(from decoder: Decoder) throws { + nonisolated init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) sessionCount = try c.decode(Int.self, forKey: .sessionCount) sshCount = try c.decode(Int.self, forKey: .sshCount) @@ -162,6 +173,7 @@ extension SessionActivityAttributes.ContentState { agentAttentionCount = try c.decodeIfPresent(Int.self, forKey: .agentAttentionCount) ?? 0 agentIdleCount = try c.decodeIfPresent(Int.self, forKey: .agentIdleCount) ?? 0 agentCountsFrozen = try c.decodeIfPresent(Bool.self, forKey: .agentCountsFrozen) ?? false + agentPushUpdatedAt = try c.decodeIfPresent(Date.self, forKey: .agentPushUpdatedAt) appIconVariant = try c.decodeIfPresent(String.self, forKey: .appIconVariant) ?? "" } } diff --git a/rootshell/UI/Settings/System/LiveActivitySettingsView.swift b/rootshell/UI/Settings/System/LiveActivitySettingsView.swift index 24e6abb93..6305f0abc 100644 --- a/rootshell/UI/Settings/System/LiveActivitySettingsView.swift +++ b/rootshell/UI/Settings/System/LiveActivitySettingsView.swift @@ -141,7 +141,7 @@ struct LiveActivitySettingsView: View { .foregroundColor(.orange) } if liveActivityManager.isAgentInfoEnabled { - Text("Agent counts come from on-device detection and update only while rootshell is in the foreground. The Lock Screen marks them as paused while rootshell is in the background.") + Text("Agent counts come from on-device detection and update while rootshell is in the foreground. In the background the Lock Screen marks them as paused, except that an agent notification from a paired computer moves that agent to needs attention.") } } }