native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687import 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 codexVersionQuery: AgentManagedHookPreparer.CodexVersionQuery @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 remoteAgentDetectionConsent: RemoteAgentDetectionConsent = .undecided private var preferredFontSize: 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) }, codexVersionQuery: @escaping AgentManagedHookPreparer.CodexVersionQuery = { await CodexVersionProbe.query(executableURL: $0) }, 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.codexVersionQuery = codexVersionQuery self.hookResourcesProvider = hookResourcesProvider self.forwardingRecordBaseDirectory = forwardingRecordBaseDirectory configureDispatchEvidence() // A remembered size equal to the config's size means "follow the config". // Builds that read the config size as 0 saved such sizes, so clear them here // and send the cleared value to Settings. if preferredFontSize != nil, normalizedFontSize(preferredFontSize) == nil { self.preferredFontSize = nil emit(.fontSizeChanged(nil)) } 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 } if handleSettingsCommand(command) { return } handleManagementCommand(command) }
/// 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, codexVersionQuery: codexVersionQuery, 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: fallbackContext(context, preparation), 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 let fallbackContext = fallbackContext(context, preparation) func degraded(_ message: String) -> Result<PreparedAgentProfileLaunch, AgentProfileLaunchError> { .success( PreparedAgentProfileLaunch( context: fallbackContext, 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: fallbackContext, 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 = context.replacingPlan(executionPlan) return .success( PreparedAgentProfileLaunch( context: preparedContext, warnings: preparation.warning.map { [$0] } ?? [] ) ) }
/// A launch without its managed hook still takes the runtime's fallback invocation. private func fallbackContext( _ context: FrozenAgentProfileLaunchContext, _ preparation: AgentManagedHookPreparation ) -> FrozenAgentProfileLaunchContext { guard let invocation = preparation.fallbackInvocation else { return context } return context.replacingPlan(context.request.plan.replacingInvocation(invocation)) }
/// 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 }
/// Settings the app pushes to every terminal state. private func handleSettingsCommand(_ command: TerminalClient.Command) -> Bool { switch command { case .setNotificationsEnabled(let enabled): setNotificationsEnabled(enabled) case .setCommandFinishedNotification(let enabled, let threshold): setCommandFinishedNotification(enabled: enabled, threshold: threshold) case .setRemoteAgentDetection(let consent): remoteAgentDetectionConsent = consent for state in states.values { state.setRemoteAgentDetectionConsent(consent) } 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 .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, let allowsMissingWorktrees): terminalLogger.info( "[LayoutRestore] received restoreLayoutSnapshot command, worktrees=\(worktrees.count)" + " allowsMissing=\(allowsMissingWorktrees)") Task { await restoreLayoutSnapshot(from: worktrees, allowsMissingWorktrees: allowsMissingWorktrees) } 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 } let evidence = refreshEvidenceEpoch(surfaceID: caller.surfaceID) switch agentObservationStore.recordManagedHook( input, callerAncestry: caller.processAncestry, surfaceID: caller.surfaceID ) { case .rejected: return false case .pending: terminalLogger.debug( "Managed hook deferred runtime=\(input.runtime.rawValue) pane=\(caller.surfaceID) " + "hookSession=\(input.signal.sessionID) detectedSession=\(evidence.sessionID ?? "unknown")" ) 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: LocalProcessGeneration?, sessionID: String?) { let evidence = currentAgentEvidence(surfaceID: surfaceID) // Managed hooks belong to a local launch; an agent on another origin retires them // with the rest of the local evidence. if evidence.isRemote { revokeManagedHook(surfaceID: surfaceID, state: nil, invalidatingEvidence: true) } handleEvidenceEpochUpdate( agentObservationStore.updateEvidenceEpoch( surfaceID: surfaceID, processGeneration: evidence.generation, sessionID: evidence.sessionID, isRemote: evidence.isRemote ), surfaceID: surfaceID ) return (evidence.generation, evidence.sessionID) }
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, observedAt: signal.timestamp) } for record in update.revokedForwardingRecords { retireForwardingRecord(record) } }
private func configureDispatchEvidence() { agentObservationStore.onEvidenceInvalidated = { [weak self] surfaceID, epoch in self?.agentDispatchStore.invalidateEvidence(surfaceID: surfaceID, evidenceEpoch: epoch) } agentDispatchStore.refreshEvidence = { [weak self] surfaceID in _ = self?.refreshEvidenceEpoch(surfaceID: surfaceID) } }
private func retireForwardingRecord(_ record: CodexForwardingRecord) { codexForwardingRecordStore?.retire(record) }
private func noteDispatchEvidence( _ signal: AgentSignal, surfaceID: UUID, evidenceEpoch: UUID, observedAt: Date? = nil ) { switch signal.kind { case .turnEnded: agentDispatchStore.noteTerminalEvidence( surfaceID: surfaceID, evidenceEpoch: evidenceEpoch, evidence: .turnEnded, observedAt: observedAt ) case .needsInput: agentDispatchStore.noteTerminalEvidence( surfaceID: surfaceID, evidenceEpoch: evidenceEpoch, evidence: .needsInput, observedAt: observedAt ) case .sessionEnd: agentDispatchStore.noteTerminalEvidence( surfaceID: surfaceID, evidenceEpoch: evidenceEpoch, evidence: .sessionEnd, observedAt: observedAt ) 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 struct CurrentAgentEvidence { var generation: LocalProcessGeneration? var sessionID: String? /// The pane's agent runs on another origin; no local evidence describes it. var isRemote = false }
private func currentAgentEvidence(surfaceID: UUID) -> CurrentAgentEvidence { for state in states.values { guard let paneState = state.surfaceAgentStates[surfaceID] else { continue } let process = paneState.launchProcess ?? paneState.agentProcess if let process, process.origin != .local { return CurrentAgentEvidence(isRemote: true) } // 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 = process?.localPID.flatMap { pid in ProcessDetection.processStartDate(pid: pid).map { LocalProcessGeneration(pid: pid, startedAt: $0) } } let trustedSessionID = paneState.session.flatMap { $0.confidence == .medium ? nil : $0.id } return CurrentAgentEvidence(generation: generation, sessionID: trustedSessionID) } return CurrentAgentEvidence() }
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 } // Starting an epoch retires the pane's evidence, so reject a second assignment first. guard agentDispatchStore.pendingSnapshot(surfaceID: surfaceID) == nil else { throw AgentDispatchStoreError.surfacePending } 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 }
/// The coordinator's current resolution for readiness (docs-ai 079): the reducer /// entry drops reason-only changes from emission, and the published pane decision /// is held back during an OSC withdrawal, so neither carries the live reason. func agentCurrentDecision(surfaceID: UUID) -> AgentStateDecision? { activeWorktreeStates.lazy.compactMap { $0.agentDetectionCoordinators[surfaceID]?.decision }.first }
/// The view of a pane that `agents wait`, `agents dispatch`, and the workflow /// runner evaluate (docs-ai 064.012/014, 079): the reducer's entry plus the live /// terminal evidence, including the coordinator's current decision. func agentConditionSnapshot(surfaceID: UUID, agent: ActiveAgentEntry?) -> AgentConditionSnapshot { let observed = agentObservationSnapshot(surfaceID: surfaceID) let evidence = currentAgentSignalEvidence(surfaceID: surfaceID) return AgentConditionSnapshot( agent: agent, signal: evidence.activeTerminal, changedSignal: evidence.latest, revision: observed?.revision ?? 0, isLive: isSurfaceLive(surfaceID), signals: agentSignalsPayload(surfaceID: surfaceID), screenDetection: agentScreenDetection(surfaceID: surfaceID), decision: agentCurrentDecision(surfaceID: surfaceID) ) }
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 revalidateAgentDispatch(_ event: AgentDispatchObservation) -> AgentDispatchObservation { agentDispatchStore.revalidate(event) }
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.setRemoteAgentDetectionConsent(remoteAgentDetectionConsent) state.onRemoteAgentConsentNeeded = { [weak self] surfaceID, destination in self?.emit( .remoteAgentConsentNeeded(worktreeID: worktree.id, surfaceID: surfaceID, destination: destination)) } 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]) { let surfaceIDs = surfaces.map(\.id) for state in states.values { state.dropProgramStatusStores(forSurfaceIDs: surfaceIDs) } for surface in surfaces { if let record = deferredForwardingRecords.removeValue(forKey: surface.id) { retireForwardingRecord(record) } surface.closeSurface() } }
private func revokeManagedHook( surfaceID: UUID, state: WorktreeTerminalState?, invalidatingEvidence: Bool = false ) { guard let record = agentObservationStore.revokeManagedHook( surfaceID: surfaceID, invalidatingEvidence: invalidatingEvidence) 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()) }
var preferredFontSizeForTesting: Float32? { preferredFontSize }
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)) }
/// `nil` when `fontSize` equals the config's size, so the config stays in charge. 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 }
/// The config's `font-size`, read each time so a reloaded config counts. private var baselineFontSize: Float32 { runtime?.defaultFontSize() ?? Self.previewBaselineFontSize }
private static let previewBaselineFontSize: Float32 = 13
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], allowsMissingWorktrees: Bool = false) 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 skippedWorktreeIDs = applyLayoutSnapshotPayload( payload, availableWorktrees: worktrees, allowsMissingWorktrees: allowsMissingWorktrees ) terminalLogger.info("[LayoutRestore] restore: applyResult=\(skippedWorktreeIDs != nil)") if let skippedWorktreeIDs { let selectedWorktreeID = payload.selectedWorktreeID.flatMap { skippedWorktreeIDs.contains($0) ? nil : $0 } terminalLogger.info( "[LayoutRestore] restore: emitting layoutRestored selectedWorktreeID=\(selectedWorktreeID ?? "nil")" ) emit(.layoutRestored(selectedWorktreeID: 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 ) }
/// Returns the IDs of the saved worktrees that were skipped, or `nil` when the /// snapshot is invalid and nothing was restored. private func applyLayoutSnapshotPayload( _ payload: TerminalLayoutSnapshotPayload, availableWorktrees: [Worktree], allowsMissingWorktrees: Bool ) -> Set<Worktree.ID>? { let worktreeByID = Dictionary( availableWorktrees.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) var restoredStates: [WorktreeTerminalState] = [] restoredStates.reserveCapacity(payload.worktrees.count) var skippedWorktreeIDs: Set<Worktree.ID> = []
for snapshot in payload.worktrees { guard let worktree = worktreeByID[snapshot.worktreeID] else { terminalLogger.warning( "[LayoutRestore] apply: worktreeID \(snapshot.worktreeID) not found in available worktrees" ) if allowsMissingWorktrees { skippedWorktreeIDs.insert(snapshot.worktreeID) continue } for state in restoredStates { state.closeAllSurfaces() } return nil } 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 nil } restoredStates.append(state) }
terminalLogger.info( "[LayoutRestore] apply: successfully restored \(restoredStates.count) worktree(s)," + " skipped \(skippedWorktreeIDs.count)") return skippedWorktreeIDs }
#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.codexVersionQuery = { _ in nil } self.hookResourcesProvider = { nil } self.forwardingRecordBaseDirectory = ProwlPaths.agentHookForwardingDirectory self.closeUndoStack = TerminalCloseUndoStack(timeout: .zero) configureDispatchEvidence() } #endif}