native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572import 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 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 // 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 } 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()) }
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]) 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.closeUndoStack = TerminalCloseUndoStack(timeout: .zero) } #endif}