native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
25 kB · 570 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571import Foundationimport GhosttyKit
extension WorktreeTerminalState { func wakeAgentDetection(forSurfaceID surfaceID: UUID) { guard let view = surfaces[surfaceID], let tabId = tabId(containing: surfaceID) else { return } wakeAgentDetection(for: view, tabId: tabId) }
func wakeAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID, now: Date = Date()) { agentDetectionSchedules[view.id] = (agentDetectionSchedules[view.id] ?? .cold).warmed(now: now) if surfaceAgentStates[view.id] == nil { surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: now) } startAgentDetectionTaskIfNeeded(for: view, tabId: tabId) }
func startAgentDetectionTaskIfNeeded(for view: GhosttySurfaceView, tabId: TerminalTabID) { guard agentDetectionTasks[view.id] == nil else { return } agentDetectionTasks[view.id] = Task { @MainActor [weak self, weak view] in while !Task.isCancelled { guard let self, let view, self.surfaces[view.id] != nil else { return } let hasAgent = await self.detectAgentState(for: view, tabId: tabId) let now = Date() // Titles land on the manager's own clock now, because a non-agent program // can strand one after this schedule goes cold. An entry cannot be // stranded that way: it exists only while a pane has a detected agent, // and that is exactly the condition keeping this loop warm. So the poll // stays the trailing flush here, and no second timer is needed. self.flushPendingAgentEntry(surfaceID: view.id, now: now) let schedule = self.agentDetectionSchedules[view.id] ?? .cold self.agentDetectionSchedules[view.id] = hasAgent ? schedule.observedAgent(now: now) : schedule.observedNoAgent(now: now)
guard let interval = self.agentDetectionSchedules[view.id]?.nextInterval(now: now) else { self.finishColdAgentDetection(forSurfaceID: view.id) return } try? await Task.sleep(for: interval) } } }
func finishColdAgentDetection(forSurfaceID surfaceID: UUID) { agentDetectionTasks.removeValue(forKey: surfaceID) agentDetectionSchedules.removeValue(forKey: surfaceID) agentDetectionPresenceBySurface.removeValue(forKey: surfaceID) lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceID) lastAgentScreenScanBySurface.removeValue(forKey: surfaceID) if surfaceAgentStates[surfaceID]?.detectedAgent == nil { surfaceAgentStates.removeValue(forKey: surfaceID) } }
typealias RetainedSessionResolver = @MainActor ( IdentifiedAgentProcess?, PaneAgentState, URL?, String, URL? ) async -> (session: AgentSession?, missStreak: Int)
func detectAgentState( for view: GhosttySurfaceView, tabId: TerminalTabID, resolveSession: RetainedSessionResolver? = nil ) async -> Bool { let surfaceID = view.id let childPID = view.bridge.childPID() let processGroupID = view.bridge.foregroundProcessGroupID() let job = await AgentProcessProbe.shared.foregroundJob(processGroupID: processGroupID, childPID: childPID) guard surfaces[surfaceID] != nil else { return false }
let identified = job.flatMap { identifyAgentInJob($0) } let probedAgent = identified?.agent
var presence = agentDetectionPresenceBySurface[surfaceID] ?? AgentDetectionPresence() let agent = presence.update(detectedAgent: probedAgent) agentDetectionPresenceBySurface[surfaceID] = presence
guard let agent else { // Only log the moment we lose a previously detected agent; pre-agent // shells churn process lists every command and would otherwise spam. if surfaceAgentStates[surfaceID]?.detectedAgent != nil { logAgentDetectionDiagnostic( surfaceID: surfaceID, diagnostic: AgentDetectionDiagnostic( tabId: tabId, childPID: childPID, processGroupID: processGroupID, job: job, identified: identified, retainedAgent: nil, raw: nil, reason: nil, stabilized: nil ) ) } removeAgentEntryIfNeeded(surfaceID: surfaceID) return false }
let now = Date() var previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now) let capturedAt = ProcessInfo.processInfo.systemUptime let activeText = view.bridge.readActiveText() ?? "" // Reuse the previous scan while the screen and detected agent are unchanged. // A live-but-idle agent is polled every 300 ms and `detectState` re-splits, // lowercases, and scans the whole screen each time; skipping that for // identical text is the bulk of steady-state detection cost. let detection = cachedScreenDetection(forSurfaceID: surfaceID, agent: agent, text: activeText) let raw = detection.state guard surfaces[surfaceID] != nil else { return false }
let iconLookupToken = identified?.iconLookupToken ?? previous.iconLookupToken ?? agent.iconLookupToken let workingDirectory = activeAgentWorkingDirectory(surfaceID: surfaceID) let (session, sessionMissStreak) = await (resolveSession ?? Self.resolveRetainedSession)( identified, previous, workingDirectory, activeText, launchProfilesBySurface[surfaceID]?.configRoot(forDetected: agent) ) // Re-check after the suspension: the pane may have been closed and its // agent state cleaned up while the resolver was doing file inspection; // writing below would resurrect a ghost Active Agents entry. guard surfaces[surfaceID] != nil else { return false } // Acknowledgement can change while session inspection is suspended. Preserve it, // but discard this observation if any other state changed in the meantime. guard let current = surfaceAgentStates[surfaceID] else { return false } previous.seen = current.seen previous.lastChangedAt = current.lastChangedAt guard previous == current else { return true } let coordinator = agentDetectionCoordinators[surfaceID] ?? AgentDetectionCoordinator() agentDetectionCoordinators[surfaceID] = coordinator let process = Self.processGeneration(identified)
guard let decision = await coordinator.observe( agent: agent, process: process, screen: detection, screenContentID: detection.state == .blocked ? activeText.hashValue : nil, capturedAt: capturedAt, configRoot: launchProfilesBySurface[surfaceID]?.configRoot(forDetected: agent) ), surfaces[surfaceID] != nil, let latest = surfaceAgentStates[surfaceID] else { return false } previous.seen = latest.seen previous.lastChangedAt = latest.lastChangedAt guard previous == latest else { return true } let stabilized = decision.state let launchObservation = resolvedLaunchObservation(identified: identified, previous: previous) let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt var next = PaneAgentState( detectedAgent: agent, // Presence holds keep the last known pid so a probe gap does not flap // the session to nil and back (the resolver re-binds on the next hit). agentProcessID: identified?.process.pid ?? previous.agentProcessID, launchProcessID: resolvedLaunchProcessID(identified: identified, previous: previous), launchObservation: launchObservation, session: session, iconLookupToken: iconLookupToken, fallbackState: raw, state: stabilized, seen: resolvedSeen(previous: previous, stabilized: stabilized, surfaceID: surfaceID), lastChangedAt: lastChangedAt ) next.sessionMissStreak = sessionMissStreak next.decision = decision // Limit logging to meaningful transitions - agent identity or // stabilized state changes. Raw oscillation and `seen` flips are // routine and would otherwise dominate the log stream. if previous.detectedAgent != agent || previous.state != stabilized { logAgentDetectionDiagnostic( surfaceID: surfaceID, diagnostic: AgentDetectionDiagnostic( tabId: tabId, childPID: childPID, processGroupID: processGroupID, job: job, identified: identified, retainedAgent: agent, raw: raw, reason: detection.reason, stabilized: stabilized ) ) } guard next != previous else { return true } surfaceAgentStates[surfaceID] = next updateTabAgentBusyState(for: tabId) emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: next) return true }
private static func processGeneration(_ identified: IdentifiedAgentProcess?) -> AgentProcessGeneration? { identified.flatMap { identified in ProcessDetection.processStartDate(pid: identified.process.pid).map { AgentProcessGeneration(pid: identified.process.pid, startedAt: $0) } } }
/// Resolves the screen detection for `text`, reusing `cache` when it already /// holds a scan for the same `agent` and identical `text`. Returns the full /// detection and the scan to store back for the next call. /// /// `detectScreen` is a `nonisolated` pure function of the screen, so reusing /// its result for identical input is exactly equivalent to recomputing it. /// It runs inline (no `Task.detached`): the detached hop bought only allocator /// churn — over a long session each tick left a task stack + closure capture /// that never reached ARC, adding up to hundreds of MB of unreferenced /// allocations. nonisolated static func resolveScreenDetection( agent: DetectedAgent, text: String, cache: AgentScreenScan? ) -> (detection: AgentScreenDetection, scan: AgentScreenScan) { if let cache, cache.agent == agent, cache.text == text { return (cache.detection, cache) } let detection = agent.detectScreen(in: text) return (detection, AgentScreenScan(agent: agent, text: text, detection: detection)) }
/// Instance wrapper over `resolveScreenDetection` that reads and writes the /// per-surface memo, keeping `detectAgentState` concise at the call site. private func cachedScreenDetection( forSurfaceID surfaceID: UUID, agent: DetectedAgent, text: String ) -> AgentScreenDetection { let (detection, scan) = Self.resolveScreenDetection( agent: agent, text: text, cache: lastAgentScreenScanBySurface[surfaceID] ) lastAgentScreenScanBySurface[surfaceID] = scan return detection }
private func resolvedSeen( previous: PaneAgentState, stabilized: AgentRawState, surfaceID: UUID ) -> Bool { if isViewedSurface(surfaceID) { return true } if (previous.state == .working || previous.state == .blocked) && stabilized == .idle { return false } return previous.seen }
private func resolvedLaunchProcessID( identified: IdentifiedAgentProcess?, previous: PaneAgentState ) -> pid_t? { PaneAgentState.retainedLaunchProcessID( identifiedLaunchProcessID: identified?.launchProcessID, identifiedProcessID: identified?.process.pid, previous: previous, isLiveAncestor: Self.processIsLiveAncestor ) }
/// Walk the live process tree upward from `descendant` (bounded), reporting whether it still /// passes through `ancestor`. Used only when the identified launch root changed, so the cost /// is a short `proc_pidinfo` chain on a rare transition, never on the steady-state poll. nonisolated static func processIsLiveAncestor(_ ancestor: pid_t, of descendant: pid_t) -> Bool { var pid = descendant var hops = 0 while pid > 1, hops < 32 { if pid == ancestor { return true } guard let info = ProcessDetection.processBSDInfo(pid: pid) else { return false } let parent = pid_t(info.pbi_ppid) if parent == pid { return false } pid = parent hops += 1 } return pid == ancestor }
private func resolvedLaunchObservation( identified: IdentifiedAgentProcess?, previous: PaneAgentState ) -> AgentLaunchObservation? { let observed = identified.flatMap { identified in identified.process.arguments.map { AgentRuntimeAdapterRegistry.observe(agent: identified.agent, arguments: $0) } } return PaneAgentState.retainedLaunchObservation( observed: observed, previous: previous, identifiedPID: identified?.process.pid ) }
private static func resolveRetainedSession( identified: IdentifiedAgentProcess?, previous: PaneAgentState, workingDirectory: URL?, activeText: String, configRoot: URL? ) async -> (session: AgentSession?, missStreak: Int) { var resolution = AgentSessionResolution(session: nil, isFresh: false) if let identified { resolution = await AgentSessionResolver.shared.resolve( identified: identified, workingDirectory: workingDirectory, activeText: activeText, configRoot: configRoot ) } return PaneAgentState.retainedSession( resolved: resolution.session, isFresh: resolution.isFresh, previous: previous, identifiedPID: identified?.process.pid ) }
func markAgentSeen(surfaceID: UUID) { // Focus bookkeeping can run while the window is inactive or before a tab is selected. guard isViewedSurface(surfaceID), var state = surfaceAgentStates[surfaceID], !state.seen else { return } state.seen = true state.lastChangedAt = Date() surfaceAgentStates[surfaceID] = state guard let tabId = tabId(containing: surfaceID) else { return } emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) }
func removeAgentEntryIfNeeded(surfaceID: UUID) { guard surfaceAgentStates[surfaceID]?.detectedAgent != nil else { return } agentDetectionCoordinators.removeValue(forKey: surfaceID)?.invalidate() surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: Date()) lastAgentScreenScanBySurface.removeValue(forKey: surfaceID) let hadPublishedEntry = lastEmittedAgentEntriesBySurface.removeValue(forKey: surfaceID) != nil // The launch identity lives exactly as long as the launched agent // (docs-ai 053/006): once the pane is a bare shell again, a manually // started agent is the user's own — default home, default account — and // must not wear the profile's name or config root. launchProfilesBySurface.removeValue(forKey: surfaceID) lastAgentEntryEmitAtBySurface.removeValue(forKey: surfaceID) pendingAgentEntryBySurface.removeValue(forKey: surfaceID) if hadPublishedEntry { onAgentEntryRemoved?(surfaceID) } if let tabId = tabId(containing: surfaceID) { updateTabAgentBusyState(for: tabId) } }
/// Recompute `tabAgentBusyById[tabId]` from the stabilized state of every /// surface in the tab, emitting a task-status change only when the aggregate /// flips. Driven by `detectAgentState` (state change), agent release, and /// surface teardown so the sidebar/`prowl list` running indicator tracks agent /// activity. `emitTaskStatusIfChanged` dedupes emissions. func updateTabAgentBusyState(for tabId: TerminalTabID) { let surfaceIDs = trees[tabId]?.leaves().map(\.id) ?? [] let isBusy = surfaceIDs.contains { surfaceAgentStates[$0]?.isBusy == true } let isBlocked = surfaceIDs.contains { surfaceAgentStates[$0]?.isBlocked == true } // Blocked is tracked even when `isBusy` is unchanged: working → blocked // leaves the aggregate busy, and that transition is exactly when the // sidebar must swap the spinner for the attention affordance. The view // observes `tabAgentBlockedById` directly, so no task-status event is // needed to redraw it. if (tabAgentBlockedById[tabId] ?? false) != isBlocked { tabAgentBlockedById[tabId] = isBlocked } guard (tabAgentBusyById[tabId] ?? false) != isBusy else { return } tabAgentBusyById[tabId] = isBusy emitTaskStatusIfChanged() }
/// Re-emit Active Agents entries for every pane in `tabId` so the panel picks /// up a fresh title snapshot. Title changes (OSC-2, focus sync, manual /// rename) don't move agent detection state, so without this nudge the /// subtitle only refreshes on the next agent state transition. Used when the /// tab's display title changes, since it is every pane's title fallback. func refreshAgentEntriesForTitleChange(in tabId: TerminalTabID) { let surfaceIDs = trees[tabId]?.leaves().map(\.id) ?? [] for surfaceID in surfaceIDs { refreshAgentEntryForTitleChange(surfaceID: surfaceID, in: tabId) } }
/// Lands tab titles held back by coalescing and refreshes the Active Agents /// entries that follow them — the same refresh a title written directly through /// `updateTitle` triggers, so the two paths cannot drift. /// /// The manager's clock-driven trailing flush uses the same refresh path. This /// synchronous seam lets callers and tests force a flush at an explicit date. @discardableResult func flushCoalescedTabTitles(now: Date = Date()) -> [TerminalTabID] { let flushed = tabManager.flushPendingTitles(now: now) for tabID in flushed { refreshAgentEntriesForTitleChange(in: tabID) } return flushed }
/// Single-pane variant for a surface whose own title changed without moving /// the tab title (e.g. an unfocused split's OSC-2 update). func refreshAgentEntryForTitleChange(surfaceID: UUID, in tabId: TerminalTabID) { guard let state = surfaceAgentStates[surfaceID], state.detectedAgent != nil, state.state != .unknown else { return } emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) }
/// Minimum spacing between emissions whose only difference is the pane title. /// Agent TUIs animate a spinner glyph into the terminal title at roughly 10 Hz; /// forwarding each frame mutates `ActiveAgentsFeature.State.entries` and dirties /// the SwiftUI graph for a change no one can perceive. One second keeps titles /// feeling live while cutting those invalidations by an order of magnitude. static let agentEntryTitleCoalescingInterval: TimeInterval = 1
func emitAgentEntry( surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState, now: Date = Date() ) { guard let entry = activeAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) else { let hadPublishedEntry = lastEmittedAgentEntriesBySurface.removeValue(forKey: surfaceID) != nil lastAgentEntryEmitAtBySurface.removeValue(forKey: surfaceID) pendingAgentEntryBySurface.removeValue(forKey: surfaceID) if hadPublishedEntry { onAgentEntryRemoved?(surfaceID) } return } if let previous = lastEmittedAgentEntriesBySurface[surfaceID] { // `rawState` flickers every poll while an agent animates and drives no UI, // so it never justifies an emission on its own. if previous.equalsIgnoringRawState(entry) { // A title sequence can return to the value the consumer already displays before the // interval ends. Any withheld intermediate frame is obsolete at that point. pendingAgentEntryBySurface.removeValue(forKey: surfaceID) return } // Only the animated title moved. Hold it back until the interval elapses; // the suppressed entry is not recorded, so the next emission carries the // title as of that moment rather than a stale frame. if previous.equalsIgnoringRawStateAndPaneTitle(entry), let lastEmitAt = lastAgentEntryEmitAtBySurface[surfaceID], now.timeIntervalSince(lastEmitAt) < Self.agentEntryTitleCoalescingInterval { pendingAgentEntryBySurface[surfaceID] = entry return } } pendingAgentEntryBySurface.removeValue(forKey: surfaceID) lastEmittedAgentEntriesBySurface[surfaceID] = entry lastAgentEntryEmitAtBySurface[surfaceID] = now onAgentEntryChanged?(entry) }
/// Emits a title-only entry that coalescing held back, once the interval has /// passed. Driven by the detection poll rather than a timer, so a spinner that /// stops mid-window still settles on its final title within one poll. func flushPendingAgentEntry(surfaceID: UUID, now: Date = Date()) { guard let pending = pendingAgentEntryBySurface[surfaceID], let lastEmitAt = lastAgentEntryEmitAtBySurface[surfaceID], now.timeIntervalSince(lastEmitAt) >= Self.agentEntryTitleCoalescingInterval else { return } pendingAgentEntryBySurface.removeValue(forKey: surfaceID) lastEmittedAgentEntriesBySurface[surfaceID] = pending lastAgentEntryEmitAtBySurface[surfaceID] = now onAgentEntryChanged?(pending) }
func activeAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) -> ActiveAgentEntry? { guard let agent = state.detectedAgent, state.state != .unknown else { return nil } let paneIDs = trees[tabId]?.leaves().map(\.id) ?? [] let paneIndex = paneIDs.firstIndex(of: surfaceID).map { $0 + 1 } ?? 1 let tabTitle = tabManager.tabs.first(where: { $0.id == tabId })?.displayTitle ?? "?" let workingDirectory = activeAgentWorkingDirectory(surfaceID: surfaceID) return ActiveAgentEntry( id: surfaceID, worktreeID: worktree.id, worktreeName: worktree.name, workingDirectory: workingDirectory, tabID: tabId, paneTitle: paneTitle(surfaceID: surfaceID, fallbackTabTitle: tabTitle), surfaceID: surfaceID, paneIndex: paneIndex, iconLookupToken: state.iconLookupToken ?? agent.iconLookupToken, agent: agent, session: state.session, stateDecision: state.decision, rawState: state.fallbackState, displayState: state.displayState, lastChangedAt: state.lastChangedAt, launchProfileName: launchProfileName(surfaceID: surfaceID, detected: agent) ) }
/// Runtime-gated like `SurfaceLaunchProfile.configRoot(forDetected:)`: after /// the launched agent exits, a manually started *different* agent in the /// same pane must not wear the old profile's name (docs-ai 053/005). func launchProfileName(surfaceID: UUID, detected agent: DetectedAgent) -> String? { guard let profile = launchProfilesBySurface[surfaceID], profile.runtime.agent == agent else { return nil } return profile.name }
func activeAgentWorkingDirectory(surfaceID: UUID) -> URL? { guard let surface = surfaces[surfaceID] else { return nil } // This can run while handling Ghostty callbacks, so use cached Swift state instead of // re-entering Ghostty for inherited surface config. if let pwd = surface.bridge.state.pwd?.trimmingCharacters(in: .whitespacesAndNewlines), !pwd.isEmpty { return URL(fileURLWithPath: pwd, isDirectory: true) } return surface.launchWorkingDirectory ?? worktree.workingDirectory }
func cleanupAgentDetectionState(forSurfaceId surfaceId: UUID) { agentDetectionCoordinators.removeValue(forKey: surfaceId)?.invalidate() agentDetectionTasks[surfaceId]?.cancel() agentDetectionTasks.removeValue(forKey: surfaceId) agentDetectionSchedules.removeValue(forKey: surfaceId) surfaceAgentStates.removeValue(forKey: surfaceId) agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceId) lastAgentScreenScanBySurface.removeValue(forKey: surfaceId) let hadPublishedEntry = lastEmittedAgentEntriesBySurface.removeValue(forKey: surfaceId) != nil lastAgentEntryEmitAtBySurface.removeValue(forKey: surfaceId) pendingAgentEntryBySurface.removeValue(forKey: surfaceId) if hadPublishedEntry { onAgentEntryRemoved?(surfaceId) } }
func cleanupAllAgentDetectionState() { for coordinator in agentDetectionCoordinators.values { coordinator.invalidate() } agentDetectionCoordinators.removeAll() for task in agentDetectionTasks.values { task.cancel() } let removedIDs = Array(lastEmittedAgentEntriesBySurface.keys) agentDetectionTasks.removeAll() agentDetectionSchedules.removeAll() surfaceAgentStates.removeAll() agentDetectionPresenceBySurface.removeAll() lastAgentDetectionDiagnosticsBySurface.removeAll() lastAgentScreenScanBySurface.removeAll() lastEmittedAgentEntriesBySurface.removeAll() lastAgentEntryEmitAtBySurface.removeAll() pendingAgentEntryBySurface.removeAll() for id in removedIDs { onAgentEntryRemoved?(id) } }
func logAgentDetectionDiagnostic(surfaceID: UUID, diagnostic: AgentDetectionDiagnostic) { #if DEBUG let message = diagnostic.summary guard lastAgentDetectionDiagnosticsBySurface[surfaceID] != message else { return } lastAgentDetectionDiagnosticsBySurface[surfaceID] = message terminalStateLogger.debug( "agent detection worktree=\(worktree.name) surface=\(surfaceID.uuidString.prefix(8)) \(message)" ) #endif }}