native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556import Foundationimport Observationimport ProwlCLISharedimport Sharingimport SwiftUI
private let terminalLogger = ProwlLogger("Terminal")private let layoutRestoreFailureMessage = "Saved terminal layout was invalid and has been reset"
@MainActor@Observablefinal class WorktreeTerminalManager { private let runtime: GhosttyRuntime? private let layoutPersistence: TerminalLayoutPersistenceClient private let skipsSurfaceCreationForTesting: Bool private let targetHandleRegistry = TerminalTargetHandleRegistry() /// App-wide undo history for pane and tab closes (docs-ai 069). One stack /// for every worktree: ⌘Z undoes the most recent close wherever it happened. @ObservationIgnored let closeUndoStack: TerminalCloseUndoStack @ObservationIgnored private var isRedoingClose = false /// Codex forwarding records of retained (undoable) closes. The hook is /// revoked at close time like any other, but the record's file stays off /// the retirement list while the process may come back: retirement cleanup /// (2 s) would otherwise delete it inside the undo window. @ObservationIgnored private var deferredForwardingRecords: [UUID: CodexForwardingRecord] = [:] @ObservationIgnored private let agentObservationStore: AgentObservationStore @ObservationIgnored private let agentDispatchStore: AgentDispatchStore @ObservationIgnored private let codexConfigReadProcess: CodexConfigReadProcess @ObservationIgnored private let codexShellEnvironmentResolver: @Sendable (URL, String?) async -> CodexShellLaunchEnvironment? @ObservationIgnored private let hookResourcesProvider: @MainActor () -> AgentHookResources? @ObservationIgnored private let forwardingRecordBaseDirectory: URL @ObservationIgnored private var codexForwardingRecordStore: CodexForwardingRecordStore? private var states: [Worktree.ID: WorktreeTerminalState] = [:] private var notificationsEnabled = true private var commandFinishedNotificationEnabled = true private var commandFinishedNotificationThreshold = 10 private var preferredFontSize: Float32? private let baselineFontSize: Float32 private var lastNotificationIndicatorCount: Int? private var eventContinuation: AsyncStream<TerminalClient.Event>.Continuation? private var pendingEvents: [TerminalClient.Event] = [] private var eventCoalescer = TerminalEventCoalescer() /// Caps the live stream and the pre-subscription backlog so a producer that /// outruns the single main-actor consumer can't grow memory without bound. private static let eventBufferCap = 2048 private static let pendingEventCap = 1024 var selectedWorktreeID: Worktree.ID? /// The worktree+tab focused in Canvas, updated by CanvasView on card tap. /// Used by toggleCanvas to know which worktree to return to. var canvasFocusedWorktreeID: Worktree.ID?
init( runtime: GhosttyRuntime, preferredFontSize: Float32? = nil, layoutPersistence: TerminalLayoutPersistenceClient = .liveValue, agentObservationBufferCapacity: Int = 64, agentDispatchStore: AgentDispatchStore = AgentDispatchStore(), codexConfigReadProcess: CodexConfigReadProcess = CodexConfigReadProcess(), codexShellEnvironmentResolver: @escaping @Sendable (URL, String?) async -> CodexShellLaunchEnvironment? = { await CodexShellLaunchEnvironmentProbe.resolve(cwd: $0, pathOverride: $1) }, hookResourcesProvider: @escaping @MainActor () -> AgentHookResources? = { guard let url = ProwlPaths.bundledCLIURL else { return nil } return AgentHookResources( bundledCLIPath: url.path(percentEncoded: false), socketPath: ProwlSocket.defaultPath, copilotPluginPath: ProwlPaths.bundledCopilotHookPluginURL?.path(percentEncoded: false), piExtensionPath: ProwlPaths.bundledPiHookExtensionURL?.path(percentEncoded: false), ompExtensionPath: ProwlPaths.bundledOMPHookExtensionURL?.path(percentEncoded: false), opencodePluginPath: ProwlPaths.bundledOpenCodeHookPluginURL?.path(percentEncoded: false) ) }, forwardingRecordBaseDirectory: URL = ProwlPaths.agentHookForwardingDirectory, undoCloseClock: any Clock<Duration> = ContinuousClock(), skipsSurfaceCreationForTesting: Bool = false ) { self.runtime = runtime self.layoutPersistence = layoutPersistence self.skipsSurfaceCreationForTesting = skipsSurfaceCreationForTesting self.preferredFontSize = preferredFontSize self.closeUndoStack = TerminalCloseUndoStack(timeout: runtime.undoTimeout(), clock: undoCloseClock) self.agentObservationStore = AgentObservationStore( bufferCapacity: agentObservationBufferCapacity) self.agentDispatchStore = agentDispatchStore self.codexConfigReadProcess = codexConfigReadProcess self.codexShellEnvironmentResolver = codexShellEnvironmentResolver self.hookResourcesProvider = hookResourcesProvider self.forwardingRecordBaseDirectory = forwardingRecordBaseDirectory baselineFontSize = runtime.defaultFontSize() closeUndoStack.onExpire = { [weak self] surfaces in self?.free(surfaces) } runtime.onAppUndo = { [weak self] in self?.undoClose() ?? false } runtime.onAppRedo = { [weak self] in self?.redoClose() ?? false } }
/// The undo/redo key with no terminal surface focused (docs-ai 069.002): /// Ghostty resolves the binding, `onAppUndo` / `onAppRedo` do the work. func performAppUndoRedoKey(_ event: NSEvent) -> Bool { runtime?.performAppUndoRedoBinding(for: event) ?? false }
func handleCommand(_ command: TerminalClient.Command) { if handleTabCommand(command) { return } if handleBindingActionCommand(command) { return } if handleSearchCommand(command) { return } handleManagementCommand(command) }
/// Creates a tab at an explicit directory and returns its ID for immediate Canvas selection. func createTabInDirectory(_ worktree: Worktree, directory: URL) -> TerminalTabID? { createTabAsync(in: worktree, runSetupScriptIfNew: false, workingDirectory: directory) }
/// Synchronous launch boundary used by the CLI and workflow runner. Menu and /// palette launch events remain owned by the compatibility command below. func launchAgentProfile( _ request: AgentProfileLaunchRequest, in worktree: Worktree ) -> Result<LaunchedSurface, AgentProfileLaunchError> { state(for: worktree).launchAgentProfile(request) }
func prepareAgentProfileLaunch( _ request: AgentProfileLaunchRequest, in worktree: Worktree ) async -> Result<PreparedAgentProfileLaunch, AgentProfileLaunchError> { let terminalState = state(for: worktree) guard terminalState.provisionAgentProfileHome(for: request.plan) else { return .failure(.homeProvisioningFailed) } var latestContext: FrozenAgentProfileLaunchContext? for attempt in 0..<2 { let context: FrozenAgentProfileLaunchContext switch terminalState.freezeAgentProfileLaunchContext(request) { case .success(let value): context = value case .failure(let error): return .failure(error) } latestContext = context let resources = hookResourcesProvider() let codexShellEnvironment: CodexShellLaunchEnvironment? if context.request.plan.runtime == .codex, resources != nil { codexShellEnvironment = await codexShellEnvironmentResolver( context.inheritedCWD, context.request.plan.profileEnvironmentOverrides["PATH"] ) } else { codexShellEnvironment = nil } guard !Task.isCancelled else { return .failure(.preparationCancelled) } let preparation = await AgentManagedHookPreparer.prepare( plan: context.request.plan, inheritedCWD: context.inheritedCWD, resources: resources, codexShellEnvironment: codexShellEnvironment, codexConfigReadProcess: codexConfigReadProcess, droidSettingsEnvironmentResolver: { cwd, pathOverride in await DroidSettingsEnvironmentProbe.resolve(cwd: cwd, pathOverride: pathOverride) }, openCodeEnvironmentResolver: { cwd, pathOverride in await ShellEnvironmentProbe.resolve( variables: OpenCodeHookPluginPreparer.environmentVariableNames, cwd: cwd, pathOverride: pathOverride ) } ) guard !Task.isCancelled else { return .failure(.preparationCancelled) } guard terminalState.isAgentProfileLaunchContextValid(context) else { if attempt == 0 { continue } let warning = LifecycleCommandWarning( code: .managedHookDegraded, runtime: request.plan.runtime.rawValue, message: "The launch target changed during managed hook preparation." ) return .success(PreparedAgentProfileLaunch(context: context, warnings: [warning])) } return applyManagedHook(preparation, context: context, resources: resources) } guard let latestContext else { return .failure(.tabCreationFailed) } return .success(PreparedAgentProfileLaunch(context: latestContext, warnings: [])) }
/// Turn a completed hook preparation into an execution-ready launch context: materialize /// any private files, patch the plan, and keep every failure path fail-open for the user's /// launch. Any file already exposed to no child is discarded before returning. private func applyManagedHook( _ preparation: AgentManagedHookPreparation, context: FrozenAgentProfileLaunchContext, resources: AgentHookResources? ) -> Result<PreparedAgentProfileLaunch, AgentProfileLaunchError> { let runtimeRawValue = context.request.plan.runtime.rawValue func degraded(_ message: String) -> Result<PreparedAgentProfileLaunch, AgentProfileLaunchError> { .success( PreparedAgentProfileLaunch( context: context, warnings: [ LifecycleCommandWarning( code: .managedHookDegraded, runtime: runtimeRawValue, message: message ) ] ) ) }
// Droid's merged settings arrive as data because only this actor owns the owner-only // store. Write it first, then render the argv against the resulting path. let settingsFile = writePendingHookSettingsFile(preparation.pendingSettingsFile) if preparation.pendingSettingsFile != nil, settingsFile == nil { return degraded("The managed hook settings file could not be created.") } var privateFiles = [settingsFile?.record].compactMap { $0 } func discardPrivateFiles() { for file in privateFiles { codexForwardingRecordStore?.discardUnexposed(file) } }
guard let capability = preparation.capability, let preparedInvocation = settingsFile?.prepared ?? preparation.preparedInvocation, let resources else { discardPrivateFiles() return .success( PreparedAgentProfileLaunch( context: context, warnings: preparation.warning.map { [$0] } ?? [] ) ) }
if let argv = preparation.forwardingArgv { guard let store = forwardingRecordStore(), let record = try? store.create(argv: argv) else { discardPrivateFiles() return degraded("The existing Codex notifier could not be preserved safely.") } privateFiles.append(record) } if Task.isCancelled { discardPrivateFiles() return .failure(.preparationCancelled) }
let executionPlan = context.request.plan.applyingManagedHook( preparedInvocation, resources: resources, launchCWD: preparation.launchCWD, token: UUID().uuidString, nativeEvents: capability.nativeEvents, coveredEvents: capability.coveredEvents, forwardingRecord: privateFiles.last ) let preparedContext = FrozenAgentProfileLaunchContext( request: AgentProfileLaunchRequest( plan: executionPlan, placement: context.request.placement, workingDirectoryOverride: context.request.workingDirectoryOverride, inheritanceAnchor: context.request.inheritanceAnchor, title: context.request.title ), inheritedCWD: context.inheritedCWD, anchorSurfaceID: context.anchorSurfaceID, tracksFocusedAnchor: context.tracksFocusedAnchor, tracksInheritedCWD: context.tracksInheritedCWD ) return .success( PreparedAgentProfileLaunch( context: preparedContext, warnings: preparation.warning.map { [$0] } ?? [] ) ) }
/// Materialize a runtime's merged hook settings into the owner-only store and render the /// argv that names it. Returns `nil` only when the file could not be created. private func writePendingHookSettingsFile( _ pending: PendingManagedHookSettingsFile? ) -> (record: CodexForwardingRecord, prepared: AgentHookPreparedInvocation)? { guard let pending, let store = forwardingRecordStore(), let record = try? store.createPrivateFile(pending.data) else { return nil } return ( record, DroidHookSettingsPreparer.applying( settingsPath: record.locator, invocation: pending.invocation, promptArgumentIndex: pending.promptArgumentIndex ) ) }
func discardPreparedAgentProfileLaunch(_ preparation: PreparedAgentProfileLaunch) { guard let record = preparation.context.request.plan.hookRegistration?.forwardingRecord else { return } codexForwardingRecordStore?.discardUnexposed(record) }
func launchPreparedAgentProfile( _ preparation: PreparedAgentProfileLaunch, in worktree: Worktree ) -> Result<LaunchedSurface, AgentProfileLaunchError> { let result = state(for: worktree).launchAgentProfile(preparation.context.request) if case .failure = result, let record = preparation.context.request.plan.hookRegistration?.forwardingRecord { codexForwardingRecordStore?.discardUnexposed(record) } return result }
func startAgentHookRuntimeMaintenance() { _ = forwardingRecordStore() }
private func forwardingRecordStore() -> CodexForwardingRecordStore? { if let codexForwardingRecordStore { return codexForwardingRecordStore } guard let store = try? CodexForwardingRecordStore(baseDirectory: forwardingRecordBaseDirectory) else { return nil } store.sweepOrphans() codexForwardingRecordStore = store return store }
/// The launch outcome is reported as an event either way: the reducer /// records the per-repo launch memory only on success and surfaces the /// failure as a toast (docs-ai 053/005). private func launchAgentProfile(_ plan: AgentProfileLaunchPlan, in worktree: Worktree) { Task { @MainActor [weak self] in guard let self else { return } var placement: AgentProfileLaunchRequest.Placement = plan.placement == .split ? .split(anchor: nil, direction: plan.splitDirection, background: false) : .tab(background: false) for attempt in 0..<2 { let request = AgentProfileLaunchRequest(plan: plan, placement: placement) switch await prepareAgentProfileLaunch(request, in: worktree) { case .failure where Task.isCancelled: return case .failure(let error): if attempt == 0, case .split = placement, error == .splitAnchorUnavailable { placement = .tab(background: false) continue } emit(.agentProfileLaunchFailed(worktreeID: worktree.id, profileName: plan.profileName)) return case .success(let preparation): switch launchPreparedAgentProfile(preparation, in: worktree) { case .success: for warning in preparation.warnings { emit( .agentProfileLaunchWarning( worktreeID: worktree.id, profileName: plan.profileName, message: warning.message ) ) } emit(.agentProfileLaunched(worktreeID: worktree.id, profileID: plan.profileID)) return case .failure: if attempt == 0, case .split = placement { placement = .tab(background: false) continue } emit(.agentProfileLaunchFailed(worktreeID: worktree.id, profileName: plan.profileName)) return } } } } }
private func handleTabCommand(_ command: TerminalClient.Command) -> Bool { switch command { case .createTab(let worktree, let runSetupScriptIfNew): Task { createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew) } case .createTabWithInput( let worktree, let input, let workingDirectory, let runSetupScriptIfNew, let autoCloseOnSuccess, let customCommandName, let customCommandIcon): Task { createTabAsync( in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, initialInput: input, workingDirectory: workingDirectory, autoCloseOnSuccess: autoCloseOnSuccess, customCommandName: customCommandName, customCommandIcon: customCommandIcon ) } case .createSplitWithInput( let worktree, let direction, let input, let autoCloseOnSuccess, let customCommandName, let customCommandIcon): Task { createSplitAsync( in: worktree, direction: direction, initialInput: input, autoCloseOnSuccess: autoCloseOnSuccess, customCommandName: customCommandName, customCommandIcon: customCommandIcon ) } case .launchAgentProfile(let worktree, let plan): launchAgentProfile(plan, in: worktree) case .createTabInDirectory(let worktree, let directory): Task { createTabAsync(in: worktree, runSetupScriptIfNew: false, workingDirectory: directory) } case .focusOrCreateTabInDirectory(let worktree, let directory, let title): state(for: worktree).focusOrCreateTab(boundToDirectory: directory, title: title) case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): let state = state(for: worktree) { runSetupScriptIfNew } state.ensureInitialTab(focusing: focusing) case .runScript(let worktree, let script): _ = state(for: worktree).runScript(script) case .insertText(let worktree, let text): if !state(for: worktree).focusAndRunCommand(text) { Task { createTabAsync( in: worktree, runSetupScriptIfNew: false, initialInput: text, autoCloseOnSuccess: false ) } } case .stopRunScript(let worktree): _ = state(for: worktree).stopRunScript() case .closeFocusedTab(let worktree): _ = closeFocusedTab(in: worktree) case .closeFocusedSurface(let worktree): _ = closeFocusedSurface(in: worktree) case .focusSelectedTab(let worktree): state(for: worktree).focusSelectedTab() default: return false } return true }
private func handleSearchCommand(_ command: TerminalClient.Command) -> Bool { switch command { case .startSearch(let worktree): state(for: worktree).performBindingActionOnFocusedSurface("start_search") case .searchSelection(let worktree): state(for: worktree).performBindingActionOnFocusedSurface("search_selection") case .navigateSearchNext(let worktree): state(for: worktree).navigateSearchOnFocusedSurface(.next) case .navigateSearchPrevious(let worktree): state(for: worktree).navigateSearchOnFocusedSurface(.previous) case .endSearch(let worktree): state(for: worktree).performBindingActionOnFocusedSurface("end_search") default: return false } return true }
private func handleBindingActionCommand(_ command: TerminalClient.Command) -> Bool { switch command { case .performBindingAction(let worktree, let action): state(for: worktree).performBindingActionOnFocusedSurface(action) case .performBindingActionOnSurface(let worktree, let surfaceID, let action): state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID) default: return false } return true }
private func handleManagementCommand(_ command: TerminalClient.Command) { switch command { case .prune(let ids): prune(keeping: ids) case .prunePreservingRepositories(let ids, let repositoryIDs): prune(keeping: ids, preservingRepositoryIDs: repositoryIDs) case .setNotificationsEnabled(let enabled): setNotificationsEnabled(enabled) case .setCommandFinishedNotification(let enabled, let threshold): setCommandFinishedNotification(enabled: enabled, threshold: threshold) case .setCanvasMode(let enabled): if enabled { terminalLogger.info( "[CanvasExit] enteringCanvas previousSelectedWorktree=\(selectedWorktreeID ?? "nil")") selectedWorktreeID = nil } case .setSelectedWorktreeID(let id): guard id != selectedWorktreeID else { return } let previousSelectedWorktreeID = selectedWorktreeID let leavingCanvas = previousSelectedWorktreeID == nil if let previousID = previousSelectedWorktreeID, let previousState = states[previousID] { previousState.lastDefocusedAt = Date() previousState.setAllSurfacesOccluded() } else if leavingCanvas { // Leaving canvas mode: occlude all worktrees except the newly selected one. for (wid, state) in states where wid != id { state.setAllSurfacesOccluded() } } selectedWorktreeID = id terminalLogger.info( "[CanvasExit] setSelectedWorktreeID previous=\(previousSelectedWorktreeID ?? "nil") " + "next=\(id ?? "nil") leavingCanvas=\(leavingCanvas) states=\(states.count)" ) terminalLogger.info("Selected worktree \(id ?? "nil")") case .saveLayoutSnapshot: terminalLogger.info("[LayoutRestore] received saveLayoutSnapshot command") Task { await persistLayoutSnapshot() } case .restoreLayoutSnapshot(let worktrees): terminalLogger.info( "[LayoutRestore] received restoreLayoutSnapshot command, worktrees=\(worktrees.count)") Task { await restoreLayoutSnapshot(from: worktrees) } case .presentTabIconPicker(let worktree): state(for: worktree).presentIconPickerForFocusedTab() default: return } }
/// Independent per-surface multicast observation. This deliberately does not /// reuse `eventStream()`, whose single production subscriber is `AppFeature`. func observeAgentState(surfaceID: UUID) -> AgentObservationStream { agentObservationStore.observe(surfaceID: surfaceID, isLive: containsSurface(surfaceID)) }
@discardableResult func recordAgentSignal(_ signal: AgentSignal, surfaceID: UUID) -> Bool { guard containsSurface(surfaceID) else { return false } agentObservationStore.publishSignal(signal, surfaceID: surfaceID) return true }
@discardableResult func recordAgentSignal(_ signal: AgentSignal, caller: CallerPane) -> AgentSignalRecordOutcome { guard containsSurface(caller.surfaceID) else { return .paneGone } let evidence = refreshEvidenceEpoch(surfaceID: caller.surfaceID) let generationMatches = evidence.generation.map(caller.belongs(to:)) ?? false let binding = agentObservationStore.bindingForSignal( surfaceID: caller.surfaceID, generationMatches: generationMatches, signalSessionID: signal.sessionID ) agentObservationStore.publishSignal(signal, binding: binding, surfaceID: caller.surfaceID) guard binding == .current, let evidenceEpoch = agentObservationStore.currentEvidenceEpoch(surfaceID: caller.surfaceID) else { return .recorded(binding: binding) } noteDispatchEvidence( signal, surfaceID: caller.surfaceID, evidenceEpoch: evidenceEpoch ) return .recorded(binding: binding) }
@discardableResult func recordAgentNativeHook(_ input: AgentNativeHookInput, caller: CallerPane) -> Bool { guard containsSurface(caller.surfaceID) else { return false } refreshEvidenceEpoch(surfaceID: caller.surfaceID) switch agentObservationStore.recordManagedHook( input, callerAncestry: caller.processAncestry, surfaceID: caller.surfaceID ) { case .rejected: return false case .pending: return true case .accepted(let signal, let evidenceEpoch): noteDispatchEvidence(signal, surfaceID: caller.surfaceID, evidenceEpoch: evidenceEpoch) return true } }
/// Reconciles the surface's evidence epoch with the agent generation and session the /// detector currently proves, activating any hook signals that were waiting for it. @discardableResult private func refreshEvidenceEpoch( surfaceID: UUID ) -> (generation: AgentProcessGeneration?, sessionID: String?) { let evidence = currentAgentEvidence(surfaceID: surfaceID) handleEvidenceEpochUpdate( agentObservationStore.updateEvidenceEpoch( surfaceID: surfaceID, processGeneration: evidence.generation, sessionID: evidence.sessionID ), surfaceID: surfaceID ) return evidence }
private func handleEvidenceEpochUpdate( _ update: AgentEvidenceEpochUpdate, surfaceID: UUID ) { for signal in update.activatedSignals { guard let epoch = agentObservationStore.currentEvidenceEpoch(surfaceID: surfaceID) else { continue } noteDispatchEvidence(signal, surfaceID: surfaceID, evidenceEpoch: epoch) } for record in update.revokedForwardingRecords { retireForwardingRecord(record) } }
private func retireForwardingRecord(_ record: CodexForwardingRecord) { codexForwardingRecordStore?.retire(record) }
private func noteDispatchEvidence( _ signal: AgentSignal, surfaceID: UUID, evidenceEpoch: UUID ) { switch signal.kind { case .turnEnded: agentDispatchStore.noteTerminalEvidence( surfaceID: surfaceID, evidenceEpoch: evidenceEpoch, evidence: .turnEnded ) case .needsInput: agentDispatchStore.noteTerminalEvidence( surfaceID: surfaceID, evidenceEpoch: evidenceEpoch, evidence: .needsInput ) case .sessionEnd: agentDispatchStore.noteTerminalEvidence( surfaceID: surfaceID, evidenceEpoch: evidenceEpoch, evidence: .sessionEnd ) case .sessionStart, .progress: agentDispatchStore.noteActivity(surfaceID: surfaceID, evidenceEpoch: evidenceEpoch) } }
/// Internal observer-health diagnostic; not a user-facing API. func agentObservationSubscriberCount(surfaceID: UUID) -> Int { agentObservationStore.subscriberCount(surfaceID: surfaceID) }
func agentObservationSnapshot(surfaceID: UUID) -> AgentObservationSnapshot? { agentObservationStore.snapshot(surfaceID: surfaceID) }
func agentEvidenceEpochForTesting(surfaceID: UUID) -> UUID? { agentObservationStore.currentEvidenceEpoch(surfaceID: surfaceID) }
func isSurfaceLive(_ surfaceID: UUID) -> Bool { containsSurface(surfaceID) }
func agentSignalsPayload( surfaceID: UUID, includeDiagnosticLast: Bool = true ) -> AgentSignalsPayload { refreshEvidenceEpoch(surfaceID: surfaceID) return agentObservationStore.signalsPayload( surfaceID: surfaceID, formatter: Self.agentSignalDateFormatter, includeDiagnosticLast: includeDiagnosticLast ) }
func currentAgentSignalEvidence(surfaceID: UUID) -> AgentCurrentSignalEvidence { _ = agentSignalsPayload(surfaceID: surfaceID) return agentObservationStore.currentSignalEvidence(surfaceID: surfaceID) }
func currentAgentSignalEvidenceSnapshot(surfaceID: UUID) -> AgentCurrentSignalEvidence { agentObservationStore.currentSignalEvidence(surfaceID: surfaceID) }
func currentEligibleAgentSignal(surfaceID: UUID) -> AgentSignal? { currentAgentSignalEvidence(surfaceID: surfaceID).activeTerminal }
private func currentAgentEvidence( surfaceID: UUID ) -> (generation: AgentProcessGeneration?, sessionID: String?) { for state in states.values { guard let paneState = state.surfaceAgentStates[surfaceID] else { continue } // The launch process, not the identified one, is the generation subject: // hooks descend from both, and only the launch survives an engine child // taking over identification. let generation = (paneState.launchProcessID ?? paneState.agentProcessID).flatMap { pid in ProcessDetection.processStartDate(pid: pid).map { AgentProcessGeneration(pid: pid, startedAt: $0) } } let trustedSessionID = paneState.session.flatMap { $0.confidence == .medium ? nil : $0.id } return (generation, trustedSessionID) } return (nil, nil) }
private static let agentSignalDateFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.timeZone = TimeZone(secondsFromGMT: 0) return formatter }()
func issueAgentDispatch() throws -> AgentDispatchSnapshot { try agentDispatchStore.issue() }
func bindAgentDispatch(dispatchID: String, target: TabResolvedTarget) throws { guard let surfaceID = UUID(uuidString: target.paneID) else { throw AgentDispatchStoreError.bindingMissing } guard let snapshot = agentDispatchStore.snapshot(dispatchID: dispatchID) else { throw AgentDispatchStoreError.notFound } let target = TabTarget(from: target) if let existing = snapshot.binding { guard existing.surfaceID == surfaceID, existing.target == target else { throw AgentDispatchStoreError.alreadyBound } try agentDispatchStore.bind(dispatchID: dispatchID, binding: existing) return } guard snapshot.record.state == .pending else { throw AgentDispatchStoreError.alreadyTerminal } let evidenceEpoch: UUID if agentObservationStore.hasManagedHook(surfaceID: surfaceID) { guard let current = agentObservationStore.currentEvidenceEpoch(surfaceID: surfaceID) else { throw AgentDispatchStoreError.bindingMissing } evidenceEpoch = current } else { evidenceEpoch = agentObservationStore.beginDispatchEpoch(surfaceID: surfaceID) } try agentDispatchStore.bind( dispatchID: dispatchID, binding: AgentDispatchBinding( surfaceID: surfaceID, target: target, evidenceEpoch: evidenceEpoch ) ) }
/// Issues a record for an agent already running in `target` and binds it to the pane's /// current evidence epoch in one main-actor step (docs-ai 064.014). Unlike a prompted /// launch this never begins a new epoch: the generation that will report the receipt is /// the one the pane holds now, so its hook and cooperative signals keep counting. func issueAgentDispatch(boundTo target: TabResolvedTarget) throws -> AgentDispatchSnapshot { guard let surfaceID = UUID(uuidString: target.paneID), containsSurface(surfaceID) else { throw AgentDispatchStoreError.bindingMissing } refreshEvidenceEpoch(surfaceID: surfaceID) guard let evidenceEpoch = agentObservationStore.currentEvidenceEpoch(surfaceID: surfaceID) else { throw AgentDispatchStoreError.bindingMissing } guard agentDispatchStore.pendingSnapshot(surfaceID: surfaceID) == nil else { throw AgentDispatchStoreError.surfacePending } let issued = try agentDispatchStore.issue() do { try agentDispatchStore.bind( dispatchID: issued.record.id, binding: AgentDispatchBinding( surfaceID: surfaceID, target: TabTarget(from: target), evidenceEpoch: evidenceEpoch ) ) } catch { agentDispatchStore.cancelIssuance(dispatchID: issued.record.id) throw error } return agentDispatchStore.snapshot(dispatchID: issued.record.id) ?? issued }
func cancelAgentDispatchIssuance(dispatchID: String) { agentDispatchStore.cancelIssuance(dispatchID: dispatchID) }
func agentScreenDetection(surfaceID: UUID) -> AgentScreenDetection? { activeWorktreeStates.lazy.compactMap { $0.lastAgentScreenScanBySurface[surfaceID]?.detection } .first }
func agentDispatchSnapshot(dispatchID: String) -> AgentDispatchSnapshot? { agentDispatchStore.snapshot(dispatchID: dispatchID) }
func pendingAgentDispatchSnapshot(surfaceID: UUID) -> AgentDispatchSnapshot? { agentDispatchStore.pendingSnapshot(surfaceID: surfaceID) }
func completeAgentDispatch( dispatchID: String, outcome: DispatchCompletionOutcome, summary: String, callerSurfaceID: UUID ) throws -> AgentDispatchMutationResult { try agentDispatchStore.complete( dispatchID: dispatchID, outcome: outcome, summary: summary, callerSurfaceID: callerSurfaceID ) }
/// Completes the caller pane's current pending dispatch (see `AgentDispatchStore.complete(surfaceID:)`). func completeAgentDispatch( surfaceID: UUID, outcome: DispatchCompletionOutcome, summary: String ) throws -> AgentDispatchMutationResult { try agentDispatchStore.complete(surfaceID: surfaceID, outcome: outcome, summary: summary) }
func abandonAgentDispatch(dispatchID: String, reason: String) throws -> AgentDispatchMutationResult { try agentDispatchStore.abandon(dispatchID: dispatchID, reason: reason) }
func observeAgentDispatch(dispatchID: String) throws -> AgentDispatchObservationStream { try agentDispatchStore.observe(dispatchID: dispatchID) }
func agentDispatchSubscriberCount(dispatchID: String) -> Int { agentDispatchStore.subscriberCount(dispatchID: dispatchID) }
func eventStream() -> AsyncStream<TerminalClient.Event> { eventContinuation?.finish() let (stream, continuation) = AsyncStream.makeStream( of: TerminalClient.Event.self, bufferingPolicy: .bufferingNewest(Self.eventBufferCap) ) eventContinuation = continuation lastNotificationIndicatorCount = nil // A new subscriber must be re-seeded with current state, so the dedup cache // can't suppress the next emit as a duplicate of one the old stream saw. eventCoalescer.reset() if !pendingEvents.isEmpty { let bufferedEvents = pendingEvents pendingEvents.removeAll() for event in bufferedEvents { if case .notificationIndicatorChanged = event { continue } continuation.yield(event) } } emitNotificationIndicatorCountIfNeeded() return stream }
func state( for worktree: Worktree, runSetupScriptIfNew: () -> Bool = { false } ) -> WorktreeTerminalState { if let existing = states[worktree.id] { existing.setDefaultFontSize(preferredFontSize) if runSetupScriptIfNew() { existing.enableSetupScriptIfNeeded() } return existing } let runSetupScript = runSetupScriptIfNew() let state = WorktreeTerminalState( runtime: runtime!, worktree: worktree, runSetupScript: runSetupScript, defaultFontSize: preferredFontSize, targetHandleRegistry: targetHandleRegistry, skipsSurfaceCreationForTesting: skipsSurfaceCreationForTesting ) state.setNotificationsEnabled(notificationsEnabled) state.setCommandFinishedNotification( enabled: commandFinishedNotificationEnabled, threshold: commandFinishedNotificationThreshold ) state.undoCloseTimeout = closeUndoStack.timeout state.onCloseRecorded = { [weak self] record in guard let self else { return } closeUndoStack.recordClose(record, clearingRedo: !isRedoingClose) } state.onRetainedSurfaceExited = { [weak self] surfaceID in self?.closeUndoStack.discardSurface(id: surfaceID) } state.onManagedHookReadopted = { [weak self] surfaceID, registration in guard let self else { return } // The close revoked the hook; the process kept its token and forwarding // record, so register the same token again under a fresh evidence epoch // and keep the record out of retirement for good. deferredForwardingRecords.removeValue(forKey: surfaceID) _ = agentObservationStore.registerManagedHook(registration, surfaceID: surfaceID) } state.onSurfacesReset = { [weak self] in self?.closeUndoStack.discard { $0 == worktree.id } } state.onUndoRequested = { [weak self] in self?.undoClose() ?? false } state.onRedoRequested = { [weak self] in self?.redoClose() ?? false } state.isSelected = { [weak self] in self?.selectedWorktreeID == worktree.id } state.onNotificationReceived = { [weak self] surfaceID, title, body, isViewed in self?.emit( .notificationReceived( worktreeID: worktree.id, surfaceID: surfaceID, title: title, body: body, isViewed: isViewed ) ) } state.onNotificationIndicatorChanged = { [weak self] in self?.emitNotificationIndicatorCountIfNeeded() } state.onTabCreated = { [weak self] in self?.emit(.tabCreated(worktreeID: worktree.id)) } state.onTabClosed = { [weak self, weak state] in guard let self else { return } let remaining = state?.tabManager.tabs.count ?? 0 emit(.tabClosed(worktreeID: worktree.id, remainingTabs: remaining)) } state.onFocusChanged = { [weak self] surfaceID in self?.emit(.focusChanged(worktreeID: worktree.id, surfaceID: surfaceID)) } state.onTaskStatusChanged = { [weak self] status in self?.emit(.taskStatusChanged(worktreeID: worktree.id, status: status)) } configureAgentObservationCallbacks(state) state.onRunScriptStatusChanged = { [weak self] isRunning in self?.emit(.runScriptStatusChanged(worktreeID: worktree.id, isRunning: isRunning)) } state.onCommandPaletteToggle = { [weak self] in self?.emit(.commandPaletteToggleRequested(worktreeID: worktree.id)) } state.onSetupScriptConsumed = { [weak self] in self?.emit(.setupScriptConsumed(worktreeID: worktree.id)) } state.onFontSizeAdjusted = { [weak self] in self?.syncPreferredFontSize(from: worktree.id) } state.onCustomCommandSucceeded = { [weak self] name, durationMs in self?.emit( .customCommandSucceeded(worktreeID: worktree.id, name: name, durationMs: durationMs)) } states[worktree.id] = state terminalLogger.info("Created terminal state for worktree \(worktree.id)") return state }
private func configureAgentObservationCallbacks(_ state: WorktreeTerminalState) { state.onAgentEntryChanged = { [weak self] entry in guard let self else { return } let beganWorking = agentObservationStore.publishAgentChanged(entry) refreshEvidenceEpoch(surfaceID: entry.surfaceID) if beganWorking, let evidenceEpoch = agentObservationStore.currentEvidenceEpoch(surfaceID: entry.surfaceID) { agentDispatchStore.noteActivity(surfaceID: entry.surfaceID, evidenceEpoch: evidenceEpoch) } emit(.agentEntryChanged(entry)) } state.onAgentEntryRemoved = { [weak self, weak state] surfaceID in guard let self else { return } revokeManagedHook(surfaceID: surfaceID, state: state) agentObservationStore.publishAgentRemoved(surfaceID: surfaceID) emit(.agentEntryRemoved(surfaceID)) } state.onSurfaceClosed = { [weak self, weak state] surfaceID in guard let self else { return } revokeManagedHook(surfaceID: surfaceID, state: state) agentObservationStore.publishSurfaceClosed(surfaceID: surfaceID) agentDispatchStore.surfaceClosed(surfaceID: surfaceID) } state.onAgentProfileSurfacePrepared = { [weak self] surfaceID, plan in guard let self, containsSurface(surfaceID) else { return false } guard let registration = plan.hookRegistration else { return true } _ = agentObservationStore.registerManagedHook(registration, surfaceID: surfaceID) return true } }
@discardableResult private func createTabAsync( in worktree: Worktree, runSetupScriptIfNew: Bool, initialInput: String? = nil, workingDirectory: URL? = nil, autoCloseOnSuccess: Bool = false, customCommandName: String? = nil, customCommandIcon: String? = nil ) -> TerminalTabID? { let state = state(for: worktree) { runSetupScriptIfNew } let setupScript: String? // Skip setup injection when auto-close is requested so the setup script's // own exit code cannot trigger the close before the user's command runs. if !autoCloseOnSuccess, state.needsSetupScript() { @SharedReader(.repositorySettings(worktree.repositoryRootURL)) var settings = RepositorySettings.default setupScript = settings.setupScript } else { setupScript = nil } let tabId = state.createTab( setupScript: setupScript, initialInput: initialInput, workingDirectoryOverride: workingDirectory ) if let tabId, let surfaceId = state.focusedSurfaceId(in: tabId) { if autoCloseOnSuccess { state.markSurfaceForAutoClose(surfaceId) } if let customCommandName { state.markSurfaceForCustomCommand(surfaceId, name: customCommandName) } if let customCommandIcon { state.applyCustomCommandIcon(customCommandIcon, surfaceId: surfaceId) } } return tabId }
private func createSplitAsync( in worktree: Worktree, direction: UserCustomSplitDirection, initialInput: String, autoCloseOnSuccess: Bool, customCommandName: String? = nil, customCommandIcon: String? = nil ) { let state = state(for: worktree) guard let newSurfaceId = state.createSplitOnFocusedSurface( direction: direction, initialInput: initialInput ) else { return } if autoCloseOnSuccess { state.markSurfaceForAutoClose(newSurfaceId) } if let customCommandName { state.markSurfaceForCustomCommand(newSurfaceId, name: customCommandName) } if let customCommandIcon { state.applyCustomCommandIcon(customCommandIcon, surfaceId: newSurfaceId) } }
@discardableResult func closeFocusedTab(in worktree: Worktree) -> Bool { let state = state(for: worktree) return state.closeFocusedTab() }
@discardableResult func closeFocusedSurface(in worktree: Worktree) -> Bool { let state = state(for: worktree) return state.closeFocusedSurface() }
/// Restores the most recent close that is still restorable. Entries whose /// worktree is gone or whose tab changed shape since the close are freed and /// skipped. Returns `false` when nothing was restored, so the key falls /// through to the terminal program. @discardableResult func undoClose() -> Bool { while let record = closeUndoStack.popUndo() { guard let state = states[record.worktreeID] else { free(record.retainedSurfaces) continue } switch record { case .pane(let worktreeID, let pane): guard state.restore(pane: pane) else { free([pane.view]) continue } closeUndoStack.recordReopen(.pane(worktreeID: worktreeID, surfaceID: pane.view.id)) emit(.tabRestored(worktreeID: worktreeID, tabID: pane.tabID)) return true case .tabs(let worktreeID, let tabs): // Each record's index was taken from the array as it shrank, so the // batch replays in reverse to land every tab back where it was. var restored: [TerminalTabID] = [] for tab in tabs.reversed() { if state.restore(tab: tab, select: tabs.count == 1 || tab.wasSelected) { restored.append(tab.tabID) } else { free(tab.tree.leaves()) } } guard !restored.isEmpty else { continue } closeUndoStack.recordReopen(.tabs(worktreeID: worktreeID, restored.reversed())) let revealed = tabs.first { $0.wasSelected && restored.contains($0.tabID) }?.tabID ?? restored.last if let revealed { emit(.tabRestored(worktreeID: worktreeID, tabID: revealed)) } return true } } return false }
/// Closes again what the last undo restored, without a confirmation prompt. @discardableResult func redoClose() -> Bool { guard let record = closeUndoStack.popRedo(), let state = states[record.worktreeID] else { return false } isRedoingClose = true defer { isRedoingClose = false } switch record { case .pane(_, let surfaceID): return state.closeSurface(id: surfaceID, confirmation: .skip) case .tabs(_, let tabIDs): let present = tabIDs.filter { id in state.tabManager.tabs.contains { $0.id == id } } guard !present.isEmpty else { return false } state.closeTabs(present) return true } }
/// Final disposal of retained surfaces: whatever the close deferred for a /// possible restore is released now. private func free(_ surfaces: [GhosttySurfaceView]) { for surface in surfaces { if let record = deferredForwardingRecords.removeValue(forKey: surface.id) { retireForwardingRecord(record) } surface.closeSurface() } }
private func revokeManagedHook(surfaceID: UUID, state: WorktreeTerminalState?) { guard let record = agentObservationStore.revokeManagedHook(surfaceID: surfaceID) else { return } if state?.isRetainedForUndo(surfaceID) == true { deferredForwardingRecords[surfaceID] = record } else { retireForwardingRecord(record) } }
func prune(keeping worktreeIDs: Set<Worktree.ID>, preservingRepositoryIDs: Set<Repository.ID> = []) { let failedRoots = Set(preservingRepositoryIDs.map { URL(fileURLWithPath: $0).standardizedFileURL }) // A failed load is not evidence that its terminal sessions were removed. let preservedIDs = states.compactMap { id, state in failedRoots.contains(state.repositoryRootURL.standardizedFileURL) ? id : nil } let worktreeIDs = worktreeIDs.union(preservedIDs) var removed: [WorktreeTerminalState] = [] var removedIDs: Set<Worktree.ID> = [] for (id, state) in states where !worktreeIDs.contains(id) { removed.append(state) removedIDs.insert(id) } closeUndoStack.discard { removedIDs.contains($0) } for state in removed { state.closeAllSurfaces() } if !removed.isEmpty { terminalLogger.info("Pruned \(removed.count) terminal state(s)") } states = states.filter { worktreeIDs.contains($0.key) } eventCoalescer.forget(worktreeIDs: removedIDs) emitNotificationIndicatorCountIfNeeded() }
var activeWorktreeStates: [WorktreeTerminalState] { states.values.filter { !$0.tabManager.tabs.isEmpty } }
func stateIfExists(for worktreeID: Worktree.ID) -> WorktreeTerminalState? { states[worktreeID] }
/// Map of shell child PIDs to their owning pane across all live worktree /// states, for the CLI service's caller-pane resolution. func paneByShellPID() -> [pid_t: CallerPane] { var map: [pid_t: CallerPane] = [:] for (worktreeID, state) in states { for (surfaceID, pid) in state.shellPIDsBySurface() { map[pid] = CallerPane(worktreeID: worktreeID, surfaceID: surfaceID) } } return map }
func stateContaining(tabId: TerminalTabID) -> WorktreeTerminalState? { activeWorktreeStates.first { $0.surfaceView(for: tabId) != nil } }
func hasManagedHookForTesting(surfaceID: UUID) -> Bool { agentObservationStore.hasManagedHook(surfaceID: surfaceID) }
func forwardingRecordStoreForTesting() -> CodexForwardingRecordStore? { forwardingRecordStore() }
private func containsSurface(_ surfaceID: UUID) -> Bool { states.values.contains { $0.surfaceView(for: surfaceID) != nil } }
@discardableResult func broadcastCommittedText( _ text: String, from primaryTabID: TerminalTabID, to selectedTabIDs: Set<TerminalTabID> ) -> Int { var mirrored = 0 for tabId in selectedTabIDs where tabId != primaryTabID { if stateContaining(tabId: tabId)?.insertCommittedText(text, in: tabId) == true { mirrored += 1 } else { terminalLogger.debug("Broadcast text failed for tab \(tabId)") } } return mirrored }
@discardableResult func broadcastMirroredKey( _ key: MirroredTerminalKey, from primaryTabID: TerminalTabID, to selectedTabIDs: Set<TerminalTabID> ) -> Int { var mirrored = 0 for tabId in selectedTabIDs where tabId != primaryTabID { if stateContaining(tabId: tabId)?.applyMirroredKey(key, in: tabId) == true { mirrored += 1 } else { terminalLogger.debug("Broadcast key failed for tab \(tabId)") } } return mirrored }
func taskStatus(for worktreeID: Worktree.ID) -> WorktreeTaskStatus? { states[worktreeID]?.taskStatus }
/// Whether the worktree holds an agent awaiting an answer. Paired with /// `taskStatus(for:)` at the sidebar row so a blocked agent reads as needing /// input rather than as work in progress. func hasBlockedAgent(for worktreeID: Worktree.ID) -> Bool { states[worktreeID]?.hasBlockedAgent == true }
func isRunScriptRunning(for worktreeID: Worktree.ID) -> Bool { states[worktreeID]?.isRunScriptRunning == true }
func setNotificationsEnabled(_ enabled: Bool) { notificationsEnabled = enabled for state in states.values { state.setNotificationsEnabled(enabled) } emitNotificationIndicatorCountIfNeeded() }
func setCommandFinishedNotification(enabled: Bool, threshold: Int) { commandFinishedNotificationEnabled = enabled commandFinishedNotificationThreshold = threshold for state in states.values { state.setCommandFinishedNotification(enabled: enabled, threshold: threshold) } }
func hasUnseenNotifications(for worktreeID: Worktree.ID) -> Bool { states[worktreeID]?.hasUnseenNotification == true }
func latestUnreadNotificationLocation() -> NotificationLocation? { var bestLocation: NotificationLocation? var bestCreatedAt: Date? for (worktreeID, state) in states { for notification in state.unreadNotifications() { if let bestCreatedAt, bestCreatedAt >= notification.createdAt { break } guard let tabID = state.tabID(containing: notification.surfaceId) else { continue } bestLocation = NotificationLocation( worktreeID: worktreeID, tabID: tabID, surfaceID: notification.surfaceId, notificationID: notification.id ) bestCreatedAt = notification.createdAt break } } return bestLocation }
@discardableResult func focusSurface(worktreeID: Worktree.ID, surfaceID: UUID) -> Bool { states[worktreeID]?.focusSurface(id: surfaceID) == true }
func markNotificationRead(worktreeID: Worktree.ID, notificationID: UUID) { states[worktreeID]?.markNotificationRead(id: notificationID) }
func markNotificationsRead(worktreeID: Worktree.ID, surfaceID: UUID) { states[worktreeID]?.markNotificationsRead(forSurfaceID: surfaceID) }
func surfaceBackgroundOpacity() -> Double { runtime?.backgroundOpacity() ?? 1.0 }
func unfocusedSplitOverlay() -> (fill: Color?, opacity: Double) { guard let runtime else { return (nil, 0) } return (runtime.unfocusedSplitFill(), runtime.unfocusedSplitOverlayOpacity()) }
func splitDividerAppearance() -> (color: Color?, width: CGFloat?) { guard let runtime else { return (nil, nil) } return (runtime.splitDividerColor(), runtime.splitDividerWidth()) }
func syncPreferredFontSize(from worktreeID: Worktree.ID) { guard let state = states[worktreeID] else { return } let fontSize = state.focusedFontSize() let normalized = normalizedFontSize(fontSize) guard preferredFontSize != normalized else { return } preferredFontSize = normalized for worktreeState in states.values { worktreeState.setDefaultFontSize(normalized) } emit(.fontSizeChanged(normalized)) }
private func normalizedFontSize(_ fontSize: Float32?) -> Float32? { guard let fontSize else { return nil } let epsilon: Float32 = 0.01 if abs(fontSize - baselineFontSize) <= epsilon { return nil } return fontSize }
private func emit(_ event: TerminalClient.Event) { guard eventCoalescer.shouldEmit(event) else { return } guard let eventContinuation else { if pendingEvents.count >= Self.pendingEventCap { pendingEvents.removeFirst() terminalLogger.debug("Dropped oldest pending terminal event (backlog cap reached)") } pendingEvents.append(event) return } eventContinuation.yield(event) }
private func emitNotificationIndicatorCountIfNeeded() { let count = states.values.reduce(0) { count, state in count + (state.hasUnseenNotification ? 1 : 0) } if count != lastNotificationIndicatorCount { lastNotificationIndicatorCount = count emit(.notificationIndicatorChanged(count: count)) } }
func persistLayoutSnapshot() async { guard let payload = makeLayoutSnapshotPayload() else { terminalLogger.info("[LayoutRestore] persist: no active states, clearing snapshot") _ = await layoutPersistence.clearSnapshot() return } terminalLogger.info("[LayoutRestore] persist: saving \(payload.worktrees.count) worktree(s)") let saved = await layoutPersistence.saveSnapshot(payload) terminalLogger.info("[LayoutRestore] persist: save result=\(saved)") }
func persistLayoutSnapshotSync() { guard let payload = makeLayoutSnapshotPayload() else { terminalLogger.info("[LayoutRestore] persistSync: no active states, clearing snapshot") discardTerminalLayoutSnapshot( at: ProwlPaths.terminalLayoutSnapshotURL, fileManager: .default) return } terminalLogger.info( "[LayoutRestore] persistSync: saving \(payload.worktrees.count) worktree(s)") let saved = saveTerminalLayoutSnapshot( payload, at: ProwlPaths.terminalLayoutSnapshotURL, cacheDirectory: ProwlPaths.cacheDirectory, fileManager: .default ) terminalLogger.info("[LayoutRestore] persistSync: save result=\(saved)") }
func restoreLayoutSnapshot(from worktrees: [Worktree]) async { terminalLogger.info("[LayoutRestore] restore: loading snapshot from disk") guard let payload = await layoutPersistence.loadSnapshot() else { terminalLogger.info("[LayoutRestore] restore: no snapshot found on disk, skipping") emit(.layoutRestored(selectedWorktreeID: nil)) return } terminalLogger.info( "[LayoutRestore] restore: loaded snapshot with \(payload.worktrees.count) worktree(s)," + " available worktrees=\(worktrees.count)" ) for (index, snapshot) in payload.worktrees.enumerated() { terminalLogger.info( "[LayoutRestore] restore: snapshot[\(index)] worktreeID=\(snapshot.worktreeID)" + " tabs=\(snapshot.tabs.count) selectedTab=\(snapshot.selectedTabID ?? "nil")" ) } for (index, worktree) in worktrees.enumerated() { terminalLogger.info( "[LayoutRestore] restore: available[\(index)] id=\(worktree.id) name=\(worktree.name)") } let didRestore = applyLayoutSnapshotPayload(payload, availableWorktrees: worktrees) terminalLogger.info("[LayoutRestore] restore: applyResult=\(didRestore)") if didRestore { terminalLogger.info( "[LayoutRestore] restore: emitting layoutRestored selectedWorktreeID=\(payload.selectedWorktreeID ?? "nil")" ) emit(.layoutRestored(selectedWorktreeID: payload.selectedWorktreeID)) } else { terminalLogger.warning( "[LayoutRestore] restore: clearing invalid snapshot and emitting failure toast") _ = await layoutPersistence.clearSnapshot() emit(.layoutRestoreFailed(message: layoutRestoreFailureMessage)) } }
private func makeLayoutSnapshotPayload() -> TerminalLayoutSnapshotPayload? { let activeStates = activeWorktreeStates.sorted { $0.worktreeID < $1.worktreeID } terminalLogger.info( "[LayoutRestore] makePayload: activeWorktreeStates=\(activeStates.count)" + " totalStates=\(states.count)" ) guard !activeStates.isEmpty else { return nil }
var snapshotWorktrees: [TerminalLayoutSnapshotPayload.SnapshotWorktree] = [] snapshotWorktrees.reserveCapacity(activeStates.count) for state in activeStates { guard let snapshot = state.makeLayoutSnapshotWorktree() else { terminalLogger.warning( "[LayoutRestore] makePayload: failed to snapshot worktree \(state.worktreeID)" ) return nil } snapshotWorktrees.append(snapshot) } return TerminalLayoutSnapshotPayload( selectedWorktreeID: selectedWorktreeID, worktrees: snapshotWorktrees ) }
private func applyLayoutSnapshotPayload( _ payload: TerminalLayoutSnapshotPayload, availableWorktrees: [Worktree] ) -> Bool { let worktreeByID = Dictionary( availableWorktrees.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) var restoredStates: [WorktreeTerminalState] = [] restoredStates.reserveCapacity(payload.worktrees.count)
for snapshot in payload.worktrees { guard let worktree = worktreeByID[snapshot.worktreeID] else { terminalLogger.warning( "[LayoutRestore] apply: worktreeID \(snapshot.worktreeID) not found in available worktrees" ) for state in restoredStates { state.closeAllSurfaces() } return false } terminalLogger.info("[LayoutRestore] apply: restoring worktree \(worktree.id)") let state = state(for: worktree) guard state.applyLayoutSnapshot(snapshot) else { terminalLogger.warning( "[LayoutRestore] apply: applyLayoutSnapshot failed for \(worktree.id)") state.closeAllSurfaces() for restored in restoredStates { restored.closeAllSurfaces() } return false } restoredStates.append(state) }
terminalLogger.info( "[LayoutRestore] apply: successfully restored \(restoredStates.count) worktree(s)") return true }
#if DEBUG /// Inert instance for SwiftUI previews — no GhosttyRuntime, all reads return defaults. static let preview: WorktreeTerminalManager = { let manager = WorktreeTerminalManager(preview: ()) return manager }()
private init(preview: Void) { self.runtime = nil self.layoutPersistence = .liveValue self.skipsSurfaceCreationForTesting = true self.preferredFontSize = nil self.agentObservationStore = AgentObservationStore(bufferCapacity: 64) self.agentDispatchStore = AgentDispatchStore() self.codexConfigReadProcess = CodexConfigReadProcess() self.codexShellEnvironmentResolver = { _, _ in nil } self.hookResourcesProvider = { nil } self.forwardingRecordBaseDirectory = ProwlPaths.agentHookForwardingDirectory self.baselineFontSize = 13 self.closeUndoStack = TerminalCloseUndoStack(timeout: .zero) } #endif}