diff --git a/Sources/PreviewAgent/main.cpp b/Sources/PreviewAgent/main.cpp index 8b109810..3b899dec 100644 --- a/Sources/PreviewAgent/main.cpp +++ b/Sources/PreviewAgent/main.cpp @@ -267,6 +267,33 @@ int main(int argc, char *argv[]) { dlsym(RTLD_DEFAULT, "NSApplicationLoad"))) Load(); + // Run the full AppKit event loop, not a bare CFRunLoop spin: windows hosted + // in this process only receive window-server events (clicks, scrolls) when + // -[NSApplication run] dequeues and dispatches them. The main dispatch + // queue (run_on_main's dispatch_sync target) drains under it as well. + // Guarded on an Aqua session being reachable: registering with the window + // server from an SSH login or launchd context can kill the process, and + // off-screen rendering works under the bare spin there. + auto *SessionDict = reinterpret_cast( + dlsym(RTLD_DEFAULT, "CGSessionCopyCurrentDictionary")); + if (SessionDict && SessionDict()) { + auto *GetClass = reinterpret_cast( + dlsym(RTLD_DEFAULT, "objc_getClass")); + auto *RegisterSel = reinterpret_cast( + dlsym(RTLD_DEFAULT, "sel_registerName")); + auto *MsgSend = reinterpret_cast( + dlsym(RTLD_DEFAULT, "objc_msgSend")); + if (GetClass && RegisterSel && MsgSend) { + if (void *AppClass = GetClass("NSApplication")) { + void *App = MsgSend(AppClass, RegisterSel("sharedApplication")); + if (App) + MsgSend(App, RegisterSel("run")); // never returns + } + } + } + + // Fallback when AppKit or the window server is unavailable: keep servicing + // the main queue. auto *RunInMode = reinterpret_cast( dlsym(RTLD_DEFAULT, "CFRunLoopRunInMode")); diff --git a/Sources/PreviewsCLI/Handlers/PreviewStartHandler.swift b/Sources/PreviewsCLI/Handlers/PreviewStartHandler.swift index b63eb4f5..ff0365d9 100644 --- a/Sources/PreviewsCLI/Handlers/PreviewStartHandler.swift +++ b/Sources/PreviewsCLI/Handlers/PreviewStartHandler.swift @@ -396,7 +396,8 @@ private func startMacOSPreview( setupModule: setupResult?.moduleName, setupType: setupResult?.typeName, setupCompilerFlags: setupResult?.compilerFlags ?? [], - setupSDKPath: setupResult?.sdkPath + setupSDKPath: setupResult?.sdkPath, + setupDylibPath: setupResult?.dylibPath ) let compileResult = try await session.compile() diff --git a/Sources/PreviewsCore/BridgeGenerator.swift b/Sources/PreviewsCore/BridgeGenerator.swift index a442db1d..55585050 100644 --- a/Sources/PreviewsCore/BridgeGenerator.swift +++ b/Sources/PreviewsCore/BridgeGenerator.swift @@ -1,5 +1,25 @@ import Foundation +/// On-screen placement for the agent's persistent preview window, baked into the render +/// entry. Applied only when the agent creates the window, so a user's later drag or resize +/// survives leaf edits. Nil means the window stays borderless and off-screen (tests, +/// headless daemons). +public struct JITRenderWindow: Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + public let title: String + + public init(x: Double, y: Double, width: Double, height: Double, title: String) { + self.x = x + self.y = y + self.width = width + self.height = height + self.title = title + } +} + /// Generates Swift source code that combines the original source file with a `@_cdecl` bridge /// entry point, allowing the preview view to be loaded via `dlopen` + `dlsym`. public enum BridgeGenerator { @@ -24,7 +44,8 @@ public enum BridgeGenerator { setupType: String? = nil, renderOutputPath: String? = nil, designTimeValuesPath: String? = nil, - stableModuleImport: String? = nil + stableModuleImport: String? = nil, + renderWindow: JITRenderWindow? = nil ) -> (source: String, literals: [LiteralEntry]) { // Transform source to replace literals with DesignTimeStore lookups let thunkResult = ThunkGenerator.transform(source: originalSource) @@ -39,9 +60,7 @@ public enum BridgeGenerator { } let modifiers = traitModifiers(traits) - let hasSetup = - setupModule != nil && setupType != nil - && isValidSwiftIdentifier(setupModule!) && isValidSwiftIdentifier(setupType!) + let hasSetup = isUsableSetup(module: setupModule, type: setupType) let setupImport = hasSetup ? "import \(setupModule!)\n" : "" let setUpEntry = hasSetup ? setUpEntryPoint(setupType: setupType!) : "" let viewCode = @@ -59,7 +78,8 @@ public enum BridgeGenerator { let renderEntry = renderOutputPath.map { renderToFileEntryPoint( - viewCode: viewCode, path: $0, valuesPath: designTimeValuesPath) + viewCode: viewCode, path: $0, valuesPath: designTimeValuesPath, + window: renderWindow) } ?? "" let bridgeCode: String switch platform { @@ -147,9 +167,7 @@ public enum BridgeGenerator { } let modifiers = traitModifiers(traits) - let hasSetup = - setupModule != nil && setupType != nil - && isValidSwiftIdentifier(setupModule!) && isValidSwiftIdentifier(setupType!) + let hasSetup = isUsableSetup(module: setupModule, type: setupType) let setupImport = hasSetup ? "\nimport \(setupModule!)" : "" let setUpEntry = hasSetup ? "\n\n\(setUpEntryPoint(setupType: setupType!))\n" : "" let viewCode = @@ -242,6 +260,32 @@ public enum BridgeGenerator { return s.range(of: pattern, options: .regularExpression) != nil } + /// Whether a setup module/type pair will actually produce setup code (the wrap and the + /// `previewSetUp` entry). Callers that advertise setup downstream (e.g. a build's + /// setupEntrySymbol) must use the same predicate, or they promise an entry the generated + /// source does not contain. + public static func isUsableSetup(module: String?, type: String?) -> Bool { + guard let module, let type else { return false } + return isValidSwiftIdentifier(module) && isValidSwiftIdentifier(type) + } + + /// Escape a runtime string (path, window title) for interpolation into a generated Swift + /// string literal. Backslash and quote would change the literal's structure; control + /// characters (a newline in a file name is legal on macOS) would split it across lines. + static func escapedForSwiftStringLiteral(_ s: String) -> String { + var out = "" + for scalar in s.unicodeScalars { + switch scalar { + case "\\": out += "\\\\" + case "\"": out += "\\\"" + case let c where c.properties.generalCategory == .control: + out += "\\u{\(String(c.value, radix: 16))}" + default: out.unicodeScalars.append(scalar) + } + } + return out + } + /// Generate the `@_cdecl("previewBodyKind")` entry point. The compiler picks the same /// `__PreviewBodyKindProbe.detect` overload that `__PreviewBridge.wrap` does, so the /// returned value reflects the outermost body kind: 1 = SwiftUI, 2 = UIView, @@ -265,13 +309,18 @@ public enum BridgeGenerator { /// blank raster for them), captures at a deterministic 1x like `Snapshot.capture`, /// and writes a PNG to `path`. Nullary so it runs over the agent's `runOnMain` /// surface; the path is baked in (the daemon recompiles per structural edit). + /// + /// The window is process-persistent: each generation's entry looks it up by + /// identifier in the agent's window list and swaps its content view, so the agent + /// keeps one live window across structural edits (single-renderer consolidation). + /// AppKit state is shared across JITDylib generations; the entry's own globals are not. private static func renderToFileEntryPoint( - viewCode: String, path: String, valuesPath: String? + viewCode: String, path: String, valuesPath: String?, window: JITRenderWindow? ) -> String { let seed = valuesPath.map { """ - if let __dtData = try? Data(contentsOf: URL(fileURLWithPath: "\($0)")), + if let __dtData = try? Data(contentsOf: URL(fileURLWithPath: "\(escapedForSwiftStringLiteral($0))")), let __dtValues = try? JSONSerialization.jsonObject(with: __dtData) as? [String: Any] { @@ -279,27 +328,53 @@ public enum BridgeGenerator { } """ } ?? "" + let createWindow: String + if let window { + let title = escapedForSwiftStringLiteral(window.title) + createWindow = """ + NSApplication.shared.setActivationPolicy(.accessory) + let created = NSWindow( + contentRect: NSRect( + x: \(window.x), y: \(window.y), + width: \(window.width), height: \(window.height)), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + created.title = "\(title)" + """ + } else { + createWindow = """ + let created = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 600), + styleMask: [.borderless], backing: .buffered, defer: false) + created.setFrameOrigin(NSPoint(x: -10_000, y: -10_000)) + """ + } return """ @_cdecl("renderPreviewToFile") public func renderPreviewToFile() -> Int32 { MainActor.assumeIsolated { \(seed) let view = \(viewCode) - let bounds = NSRect(x: 0, y: 0, width: 400, height: 600) - let window = NSWindow( - contentRect: bounds, styleMask: [.borderless], backing: .buffered, - defer: false) + let identifier = NSUserInterfaceItemIdentifier("previewsmcp-preview") + let window = + NSApplication.shared.windows.first { $0.identifier == identifier } + ?? { + \(createWindow) + created.identifier = identifier + created.isReleasedWhenClosed = false + return created + }() let hosting = NSHostingView(rootView: view) + hosting.sizingOptions = [] window.contentView = hosting - window.setFrameOrigin(NSPoint(x: -10_000, y: -10_000)) window.orderFrontRegardless() hosting.layoutSubtreeIfNeeded() - defer { window.orderOut(nil) } - guard + let bounds = hosting.bounds + guard bounds.width > 0, bounds.height > 0, let rep = NSBitmapImageRep( bitmapDataPlanes: nil, - pixelsWide: Int(bounds.width), - pixelsHigh: Int(bounds.height), + pixelsWide: Int(bounds.width.rounded()), + pixelsHigh: Int(bounds.height.rounded()), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, @@ -315,7 +390,7 @@ public enum BridgeGenerator { return Int32(-2) } do { - try data.write(to: URL(fileURLWithPath: "\(path)")) + try data.write(to: URL(fileURLWithPath: "\(escapedForSwiftStringLiteral(path))")) } catch { return Int32(-3) } diff --git a/Sources/PreviewsCore/Compiler.swift b/Sources/PreviewsCore/Compiler.swift index c9d2cf55..b3340d41 100644 --- a/Sources/PreviewsCore/Compiler.swift +++ b/Sources/PreviewsCore/Compiler.swift @@ -89,31 +89,7 @@ public actor Compiler { let uniqueName = "\(moduleName)_\(compilationCounter)" let sourceFile = workDir.appendingPathComponent("\(uniqueName).swift") let dylibFile = workDir.appendingPathComponent("\(uniqueName).dylib") - let effectiveSDK = overrideSDK ?? sdkPath - - // Layer 3 guard for issue #170: if the caller passes an SDK that no - // longer exists (e.g. user upgraded Xcode after a SetupBuilder build - // landed in cache, or hand-supplied a bogus path), fail fast with an - // actionable error before swiftc surfaces a generic "cannot find SDK" - // diagnostic that doesn't hint at the cache-staleness root cause. - if let overrideSDK, !FileManager.default.fileExists(atPath: overrideSDK) { - throw CompilationError( - message: - "Setup module was built against SDK at \(overrideSDK), which " - + "no longer exists on disk. The active toolchain resolves to " - + "\(sdkPath). Delete the setup cache (.build/previewsmcp-setup-cache) " - + "or rebuild the setup package to capture the current SDK.", - stderr: "", - exitCode: 1 - ) - } - - if let overrideSDK, overrideSDK != sdkPath { - Log.warn( - "compileCombined: setup SDK differs from active toolchain SDK " - + "(setup=\(overrideSDK), default=\(sdkPath)). Inheriting setup " - + "SDK to keep swiftmodule load consistent.") - } + let effectiveSDK = try resolveSDK(overrideSDK) Log.info( "compileCombined: module=\(moduleName) platform=\(platform) " @@ -151,7 +127,8 @@ public actor Compiler { public func compileObject( source: String, moduleName: String, - extraFlags: [String] = [] + extraFlags: [String] = [], + overrideSDK: String? = nil ) async throws -> URL { compilationCounter += 1 let uniqueName = "\(moduleName)_\(compilationCounter)" @@ -165,7 +142,7 @@ public actor Compiler { "-emit-object", "-parse-as-library", "-target", targetTriple, - "-sdk", sdkPath, + "-sdk", try resolveSDK(overrideSDK), "-module-name", moduleName, "-Onone", "-gnone", @@ -193,7 +170,8 @@ public actor Compiler { public func emitStableModule( sources: [String], moduleName: String, - extraFlags: [String] = [] + extraFlags: [String] = [], + overrideSDK: String? = nil ) async throws -> StableModule { compilationCounter += 1 let moduleDir = workDir.appendingPathComponent( @@ -209,7 +187,7 @@ public actor Compiler { return try await emitStableModule( sourceFiles: sourceFiles, moduleName: moduleName, moduleDir: moduleDir, - extraFlags: extraFlags) + extraFlags: extraFlags, overrideSDK: overrideSDK) } /// File-based variant: compile existing project sources in place (their real paths) into @@ -217,7 +195,8 @@ public actor Compiler { public func emitStableModule( sourceFiles: [URL], moduleName: String, - extraFlags: [String] = [] + extraFlags: [String] = [], + overrideSDK: String? = nil ) async throws -> StableModule { compilationCounter += 1 let moduleDir = workDir.appendingPathComponent( @@ -225,14 +204,15 @@ public actor Compiler { try FileManager.default.createDirectory(at: moduleDir, withIntermediateDirectories: true) return try await emitStableModule( sourceFiles: sourceFiles, moduleName: moduleName, moduleDir: moduleDir, - extraFlags: extraFlags) + extraFlags: extraFlags, overrideSDK: overrideSDK) } private func emitStableModule( sourceFiles: [URL], moduleName: String, moduleDir: URL, - extraFlags: [String] + extraFlags: [String], + overrideSDK: String? ) async throws -> StableModule { let objectFile = moduleDir.appendingPathComponent("\(moduleName).o") let moduleFile = moduleDir.appendingPathComponent("\(moduleName).swiftmodule") @@ -244,7 +224,7 @@ public actor Compiler { "-parse-as-library", "-enable-testing", "-target", targetTriple, - "-sdk", sdkPath, + "-sdk", try resolveSDK(overrideSDK), "-module-name", moduleName, "-Onone", "-gnone", @@ -274,7 +254,8 @@ public actor Compiler { overlaySource: String, bulkFiles: [URL], moduleName: String, - extraFlags: [String] = [] + extraFlags: [String] = [], + overrideSDK: String? = nil ) async throws -> (overlayObject: URL, bulkObjects: [URL]) { let dir: URL if let existing = incrementalDirs[moduleName] { @@ -317,7 +298,7 @@ public actor Compiler { "-emit-object", "-parse-as-library", "-target", targetTriple, - "-sdk", sdkPath, + "-sdk", try resolveSDK(overrideSDK), "-module-name", moduleName, "-Onone", "-gnone", @@ -334,6 +315,32 @@ public actor Compiler { // MARK: - Private + /// Resolve the SDK for a compile, honoring a setup-module override. Layer 3 guard for + /// issue #170: if the override SDK no longer exists (e.g. user upgraded Xcode after a + /// SetupBuilder build landed in cache), fail fast with an actionable error before swiftc + /// surfaces a generic "cannot find SDK" diagnostic that doesn't hint at cache staleness. + private func resolveSDK(_ overrideSDK: String?) throws -> String { + guard let overrideSDK else { return sdkPath } + guard FileManager.default.fileExists(atPath: overrideSDK) else { + throw CompilationError( + message: + "Setup module was built against SDK at \(overrideSDK), which " + + "no longer exists on disk. The active toolchain resolves to " + + "\(sdkPath). Delete the setup cache (.build/previewsmcp-setup-cache) " + + "or rebuild the setup package to capture the current SDK.", + stderr: "", + exitCode: 1 + ) + } + if overrideSDK != sdkPath { + Log.warn( + "compile: setup SDK differs from active toolchain SDK " + + "(setup=\(overrideSDK), default=\(sdkPath)). Inheriting setup " + + "SDK to keep swiftmodule load consistent.") + } + return overrideSDK + } + @discardableResult private func run(_ args: [String]) async throws -> String { let output = try await runAsync(args[0], arguments: Array(args.dropFirst())) diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 2f544aed..79fe7e76 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -31,6 +31,9 @@ public struct JITRenderBuild: Sendable { /// name, so its `@Observable DesignTimeStore` would re-register across generations in one /// process; a fresh process each structural edit sidesteps the duplicate registration. public let requiresFreshAgent: Bool + /// The generated `@_cdecl` setup entry (`previewSetUp`), present when the session has a + /// setup plugin. The reloader runs it once per agent process before the first render. + public let setupEntrySymbol: String? public init( objectPath: URL, @@ -41,7 +44,8 @@ public struct JITRenderBuild: Sendable { supportObjectPaths: [URL] = [], archivePaths: [URL] = [], dylibPaths: [URL] = [], - requiresFreshAgent: Bool = false + requiresFreshAgent: Bool = false, + setupEntrySymbol: String? = nil ) { self.objectPath = objectPath self.imagePath = imagePath @@ -52,6 +56,7 @@ public struct JITRenderBuild: Sendable { self.archivePaths = archivePaths self.dylibPaths = dylibPaths self.requiresFreshAgent = requiresFreshAgent + self.setupEntrySymbol = setupEntrySymbol } } @@ -69,6 +74,7 @@ public actor PreviewSession { private let setupType: String? private let setupCompilerFlags: [String] private let setupSDKPath: String? + private let setupDylibPath: URL? private var compilationResult: CompilationResult? private var lastOriginalSource: String? private var lastLiterals: [LiteralEntry]? @@ -96,7 +102,8 @@ public actor PreviewSession { setupModule: String? = nil, setupType: String? = nil, setupCompilerFlags: [String] = [], - setupSDKPath: String? = nil + setupSDKPath: String? = nil, + setupDylibPath: URL? = nil ) { self.id = UUID().uuidString self.sourceFile = sourceFile @@ -109,6 +116,7 @@ public actor PreviewSession { self.setupType = setupType self.setupCompilerFlags = setupCompilerFlags self.setupSDKPath = setupSDKPath + self.setupDylibPath = setupDylibPath } /// Run the full pipeline and return the compiled dylib path + literal map. @@ -206,7 +214,7 @@ public actor PreviewSession { /// file is the editable unit, `@testable import`ing a stable module prebuilt from the /// target's other sources, so an edit recompiles only the hot file. Standalone mode /// compiles the self-contained combined source as one object. - public func compileObjectForJIT() async throws -> JITRenderBuild { + public func compileObjectForJIT(window: JITRenderWindow? = nil) async throws -> JITRenderBuild { let source = try String(contentsOf: sourceFile, encoding: .utf8) let previews = PreviewParser.parse(source: source) @@ -229,15 +237,27 @@ public actor PreviewSession { return (ctx, bulk) } + let hasSetup = + splitContext != nil + && BridgeGenerator.isUsableSetup(module: setupModule, type: setupType) + + var stable: Compiler.StableModule? + if let (ctx, bulk) = splitContext { + stable = try await stableModuleIfLeaf(for: bulk, context: ctx) + } + let generated = BridgeGenerator.generateCombinedSource( originalSource: source, closureBody: preview.closureBody, previewIndex: previewIndex, platform: platform, traits: traits, + setupModule: hasSetup ? setupModule : nil, + setupType: hasSetup ? setupType : nil, renderOutputPath: imagePath.path, designTimeValuesPath: valuesPath.path, - stableModuleImport: splitContext?.0.moduleName + stableModuleImport: stable != nil ? splitContext?.0.moduleName : nil, + renderWindow: window ) let objectPath: URL @@ -252,32 +272,29 @@ public actor PreviewSession { } dylibPaths = Self.dependencyDylibs(in: ctx.compilerFlags) - if let stable = try await stableModuleIfLeaf(for: bulk, context: ctx) { + if let path = setupDylibPath, hasSetup { + dylibPaths.insert(path, at: 0) + } + + if let stable { supportObjectPaths = [stable.objectPath] objectPath = try await compiler.compileObject( source: generated.source, moduleName: "PreviewEdit_\(ctx.moduleName)_\(Self.uniqueModuleToken())", extraFlags: ["-I", stable.modulesDir.path] + ctx.compilerFlags + + setupCompilerFlags, + overrideSDK: setupSDKPath ) } else { // Non-leaf: the bulk references the edited file, so it cannot be prebuilt as a // one-directional stable module. Compile the whole module incrementally with the // overlay in-module (no `@testable import`); only the hot file recompiles per edit. - let overlay = BridgeGenerator.generateCombinedSource( - originalSource: source, - closureBody: preview.closureBody, - previewIndex: previewIndex, - platform: platform, - traits: traits, - renderOutputPath: imagePath.path, - designTimeValuesPath: valuesPath.path, - stableModuleImport: nil - ) let built = try await compiler.compileModuleIncremental( - overlaySource: overlay.source, + overlaySource: generated.source, bulkFiles: bulk, moduleName: ctx.moduleName, - extraFlags: ctx.compilerFlags + extraFlags: ctx.compilerFlags + setupCompilerFlags, + overrideSDK: setupSDKPath ) supportObjectPaths = built.bulkObjects objectPath = try Self.uniqueObjectCopy(of: built.overlayObject) @@ -303,7 +320,8 @@ public actor PreviewSession { supportObjectPaths: supportObjectPaths, archivePaths: archivePaths, dylibPaths: dylibPaths, - requiresFreshAgent: requiresFreshAgent + requiresFreshAgent: requiresFreshAgent, + setupEntrySymbol: hasSetup ? "previewSetUp" : nil ) lastJITBuild = build return build @@ -415,7 +433,8 @@ public actor PreviewSession { let module = try await compiler.emitStableModule( sourceFiles: bulk, moduleName: ctx.moduleName, - extraFlags: ctx.compilerFlags + extraFlags: ctx.compilerFlags, + overrideSDK: setupSDKPath ) cachedStableModule = (key, module) return module @@ -489,16 +508,29 @@ public actor PreviewSession { /// Switch to a different preview index and recompile. Traits are preserved. @State is lost. /// Rolls back the index if compilation fails. public func switchPreview(to newIndex: Int) async throws -> CompileResult { - let oldIndex = self.previewIndex - self.previewIndex = newIndex + try await withPreviewIndex(newIndex) { try await compile() } + } + + /// Apply a preview-index switch around `compile`, rolling the index back if it throws. + /// Shared by the dylib and JIT switch paths so their rollback semantics cannot diverge. + private func withPreviewIndex( + _ newIndex: Int, compile: () async throws -> T + ) async throws -> T { + let oldIndex = previewIndex + previewIndex = newIndex do { return try await compile() } catch { - self.previewIndex = oldIndex + previewIndex = oldIndex throw error } } + /// The single definition of configure-merge semantics, shared by the dylib and JIT paths. + private func applyReconfigure(traits: PreviewTraits, clearing: Set) { + self.traits = self.traits.merged(with: traits).clearing(clearing) + } + /// Update traits and recompile. Returns the new dylib. @State is lost. /// /// - Parameters: @@ -511,7 +543,7 @@ public actor PreviewSession { traits: PreviewTraits, clearing: Set = [] ) async throws -> CompileResult { - self.traits = self.traits.merged(with: traits).clearing(clearing) + applyReconfigure(traits: traits, clearing: clearing) return try await compile() } @@ -522,6 +554,32 @@ public actor PreviewSession { return try await compile() } + /// JIT counterpart of `switchPreview`: same index mutation and rollback-on-failure, + /// compiled for the agent render path instead of a dylib. + public func switchPreviewForJIT( + to newIndex: Int, window: JITRenderWindow? = nil + ) async throws -> JITRenderBuild { + try await withPreviewIndex(newIndex) { try await compileObjectForJIT(window: window) } + } + + /// JIT counterpart of `reconfigure`: merge-and-clear traits, compile for the agent. + public func reconfigureForJIT( + traits: PreviewTraits, + clearing: Set = [], + window: JITRenderWindow? = nil + ) async throws -> JITRenderBuild { + applyReconfigure(traits: traits, clearing: clearing) + return try await compileObjectForJIT(window: window) + } + + /// JIT counterpart of `setTraits`: replace traits entirely, compile for the agent. + public func setTraitsForJIT( + _ newTraits: PreviewTraits, window: JITRenderWindow? = nil + ) async throws -> JITRenderBuild { + self.traits = newTraits + return try await compileObjectForJIT(window: window) + } + /// A fresh, globally-unique module-name suffix (a valid Swift identifier) so the editable /// unit's classes (e.g. `DesignTimeStore`) mangle distinctly on every compile. Without it, /// the capped-persistent agent re-registers the same ObjC class across generations. diff --git a/Sources/PreviewsEngine/MacOSPreviewHandle.swift b/Sources/PreviewsEngine/MacOSPreviewHandle.swift index afe0f39f..5eb9b9f4 100644 --- a/Sources/PreviewsEngine/MacOSPreviewHandle.swift +++ b/Sources/PreviewsEngine/MacOSPreviewHandle.swift @@ -39,23 +39,49 @@ public actor MacOSPreviewHandle: PreviewSessionHandle { /// and is what tipped CI's `preview_variants` test over its 60s /// callTool budget. public func setTraits(_ traits: PreviewTraits) async throws { - let result = try await session.setTraits(traits) - try await MainActor.run { - try host.loadPreview(sessionID: id, dylibPath: result.dylibPath) - } + try await reload( + jit: { try await self.session.setTraitsForJIT(traits, window: $0) }, + dylib: { try await self.session.setTraits(traits) }) } public func reconfigure(traits: PreviewTraits, clearing: Set) async throws { - let result = try await session.reconfigure(traits: traits, clearing: clearing) - try await MainActor.run { - try host.loadPreview(sessionID: id, dylibPath: result.dylibPath) - } + try await reload( + jit: { try await self.session.reconfigureForJIT(traits: traits, clearing: clearing, window: $0) }, + dylib: { try await self.session.reconfigure(traits: traits, clearing: clearing) }) } public func switchPreview(to index: Int) async throws { - let result = try await session.switchPreview(to: index) - try await MainActor.run { - try host.loadPreview(sessionID: id, dylibPath: result.dylibPath) + try await reload( + jit: { try await self.session.switchPreviewForJIT(to: index, window: $0) }, + dylib: { try await self.session.switchPreview(to: index) }) + } + + /// The single routing seam for every mutating reload. An agent-backed session (JIT + /// reloader present and an agent render recorded) re-renders in the agent; reloading a + /// dylib into the daemon window instead would resurrect it as a second, stale surface. + private func reload( + jit: (JITRenderWindow?) async throws -> JITRenderBuild, + dylib: () async throws -> CompileResult + ) async throws { + enum Surface { + case agent(JITRenderWindow?) + case daemonWindow + } + let surface: Surface = await MainActor.run { + guard host.structuralReloader != nil, host.agentSnapshotPath(for: id) != nil else { + return .daemonWindow + } + return .agent(host.agentWindowSpec(for: id)) + } + switch surface { + case .agent(let window): + let build = try await jit(window) + try await host.jitRender(sessionID: id, build: build) + case .daemonWindow: + let result = try await dylib() + try await MainActor.run { + try host.loadPreview(sessionID: id, dylibPath: result.dylibPath) + } } } diff --git a/Sources/PreviewsJITLink/JITStructuralReloader.swift b/Sources/PreviewsJITLink/JITStructuralReloader.swift index d96466b1..1872fc01 100644 --- a/Sources/PreviewsJITLink/JITStructuralReloader.swift +++ b/Sources/PreviewsJITLink/JITStructuralReloader.swift @@ -11,6 +11,7 @@ public actor JITStructuralReloader: StructuralReloader { private var session: JITSession? private var generation = 0 private var lastObjectPath: URL? + private var didRunSetUp = false public init(generationCap: Int = 100) { self.generationCap = generationCap @@ -37,6 +38,13 @@ public actor JITStructuralReloader: StructuralReloader { } try session.addObject(path: build.objectPath.path) lastObjectPath = build.objectPath + // Setup runs once per agent process (its plugin state lives for the process's + // lifetime), so re-run after a respawn but not per generation. The entry is + // void; the wrapper's status word is meaningless for it. + if let setupEntry = build.setupEntrySymbol, !didRunSetUp { + _ = try session.runOnMain(symbol: setupEntry) + didRunSetUp = true + } try Self.run(session, build.entrySymbol) } @@ -60,6 +68,7 @@ public actor JITStructuralReloader: StructuralReloader { let fresh = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) session = fresh generation = 1 + didRunSetUp = false return fresh } } diff --git a/Sources/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index b804e8cd..e6d3817d 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -356,12 +356,44 @@ public class PreviewHost: NSObject, NSApplicationDelegate { /// Structural reload via the JIT path: compile the preview to a render-bridge /// object, render it in the agent, and record the agent's PNG for snapshots. /// Returns the image path, or nil if no reloader is injected (non-JIT build). + /// + /// For a visible session the daemon's window frame and title are baked into the + /// bridge so the agent shows its own live window there, and the daemon's dylib + /// window is ordered out after the first successful agent render — the agent + /// window is the interactive surface from then on. @discardableResult public func jitStructuralReload(sessionID: String, session: PreviewSession) async throws -> URL? { - guard let reloader = structuralReloader else { return nil } - let build = try await session.compileObjectForJIT() + guard structuralReloader != nil else { return nil } + let build = try await session.compileObjectForJIT(window: agentWindowSpec(for: sessionID)) + return try await jitRender(sessionID: sessionID, build: build) + } + + /// The window placement to bake into a JIT build for this session: the daemon + /// window's frame and title when the session is visible, nil when headless. + public func agentWindowSpec(for sessionID: String) -> JITRenderWindow? { + windows[sessionID].flatMap { window -> JITRenderWindow? in + guard window.styleMask.contains(.titled) else { return nil } + return JITRenderWindow( + x: window.frame.origin.x, + y: window.frame.origin.y, + width: window.contentLayoutRect.width, + height: window.contentLayoutRect.height, + title: window.title + ) + } + } + + /// Render a JIT build in the agent and make its PNG the session's snapshot source. + /// The daemon's dylib window is ordered out — once a session is agent-backed, the + /// agent's window is the interactive surface. + @discardableResult + public func jitRender(sessionID: String, build: JITRenderBuild) async throws -> URL { + guard let reloader = structuralReloader else { + throw SnapshotError.captureFailed + } try await reloader.render(build) agentImagePaths[sessionID] = build.imagePath + windows[sessionID]?.orderOut(nil) return build.imagePath } diff --git a/Tests/PreviewsCoreTests/BridgeGeneratorSetupGuardTests.swift b/Tests/PreviewsCoreTests/BridgeGeneratorSetupGuardTests.swift new file mode 100644 index 00000000..234bf660 --- /dev/null +++ b/Tests/PreviewsCoreTests/BridgeGeneratorSetupGuardTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing + +@testable import PreviewsCore + +@Suite("BridgeGenerator setup and escaping guards") +struct BridgeGeneratorSetupGuardTests { + + @Test("isUsableSetup rejects non-identifier module or type names") + func isUsableSetupValidation() { + #expect(BridgeGenerator.isUsableSetup(module: "MySetup", type: "MySetup")) + #expect(BridgeGenerator.isUsableSetup(module: "My.Setup", type: "MySetup")) + #expect(!BridgeGenerator.isUsableSetup(module: nil, type: "MySetup")) + #expect(!BridgeGenerator.isUsableSetup(module: "MySetup", type: nil)) + #expect(!BridgeGenerator.isUsableSetup(module: "My-Setup", type: "MySetup")) + #expect(!BridgeGenerator.isUsableSetup(module: "MySetup", type: "My Setup")) + } + + @Test("escapedForSwiftStringLiteral neutralizes quotes, backslashes, and control characters") + func stringLiteralEscaping() { + #expect(BridgeGenerator.escapedForSwiftStringLiteral(#"a"b"#) == #"a\"b"#) + #expect(BridgeGenerator.escapedForSwiftStringLiteral(#"a\b"#) == #"a\\b"#) + #expect(BridgeGenerator.escapedForSwiftStringLiteral("a\nb") == #"a\u{a}b"#) + #expect(BridgeGenerator.escapedForSwiftStringLiteral("a\tb") == #"a\u{9}b"#) + #expect(BridgeGenerator.escapedForSwiftStringLiteral("plain.swift") == "plain.swift") + } + + @Test("render entry with a newline-bearing title still generates compilable source text") + func titleWithNewlineGeneratesSingleLineLiteral() { + let generated = BridgeGenerator.generateCombinedSource( + originalSource: "import SwiftUI\n\n#Preview { Text(\"hi\") }", + closureBody: "Text(\"hi\")", + renderOutputPath: "/tmp/out.png", + renderWindow: JITRenderWindow( + x: 0, y: 0, width: 100, height: 100, title: "Preview: a\nb.swift") + ) + #expect(generated.source.contains(#"created.title = "Preview: a\u{a}b.swift""#)) + } +} diff --git a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift index 7c3d1121..78bef995 100644 --- a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift +++ b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift @@ -11,7 +11,10 @@ import Testing struct MacOSPreviewHandleAgentSnapshotTests { final class RecordingReloader: StructuralReloader, @unchecked Sendable { - func render(_ build: JITRenderBuild) async throws {} + private(set) var builds: [JITRenderBuild] = [] + func render(_ build: JITRenderBuild) async throws { + builds.append(build) + } } @Test func snapshotReturnsAgentImage() async throws { @@ -57,6 +60,52 @@ struct MacOSPreviewHandleAgentSnapshotTests { #expect(color.blueComponent < 0.2) } + @Test func agentBackedSwitchAndConfigureRouteThroughReloader() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34ci3c-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + try """ + import SwiftUI + + #Preview { + Color.red.frame(width: 8, height: 8) + } + + #Preview("Blue") { + Color.blue.frame(width: 8, height: 8) + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + let host = PreviewHost() + let reloader = RecordingReloader() + host.structuralReloader = reloader + + _ = try await host.jitStructuralReload(sessionID: "s1", session: session) + #expect(reloader.builds.count == 1) + let handle = MacOSPreviewHandle(id: "s1", session: session, host: host) + + try await handle.switchPreview(to: 1) + #expect(reloader.builds.count == 2) + #expect(host.agentSnapshotPath(for: "s1") == reloader.builds.last?.imagePath) + + try await handle.reconfigure(traits: PreviewTraits(colorScheme: "dark"), clearing: []) + #expect(reloader.builds.count == 3) + #expect(host.agentSnapshotPath(for: "s1") == reloader.builds.last?.imagePath) + + await #expect(throws: (any Error).self) { + try await handle.switchPreview(to: 99) + } + #expect(reloader.builds.count == 3) + + try await handle.switchPreview(to: 0) + #expect(reloader.builds.count == 4) + } + private static func greenPNG() throws -> Data { guard let rep = NSBitmapImageRep( diff --git a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift index 4f7ac6ac..04cceea3 100644 --- a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift +++ b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift @@ -101,6 +101,122 @@ struct CappedPersistentTests { } } + @Test func bridgeReusesPreviewWindowAcrossGenerations() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("window-reuse-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + + var builds: [JITRenderBuild] = [] + for body in ["Color.red", "Color.blue"] { + try """ + import SwiftUI + + #Preview { + \(body).frame(width: 8, height: 8) + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + builds.append(try await session.compileObjectForJIT()) + } + + let probe = try await compiler.compileObject( + source: """ + import AppKit + + @_cdecl("preview_window_probe") + public func preview_window_probe() -> Int32 { + MainActor.assumeIsolated { + Int32( + NSApplication.shared.windows.filter { + $0.identifier?.rawValue == "previewsmcp-preview" + }.count) + } + } + """, + moduleName: "WindowProbeFixture" + ) + + let agent = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + for (index, build) in builds.enumerated() { + if index > 0 { try agent.newGeneration() } + try agent.addObject(path: build.objectPath.path) + #expect(try agent.runOnMain(symbol: build.entrySymbol) == 0) + let rep = try #require( + NSBitmapImageRep(data: Data(contentsOf: build.imagePath))) + let color = try #require( + rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB)) + #expect((index == 0) == (color.redComponent > 0.5)) + #expect((index == 1) == (color.blueComponent > 0.5)) + } + try agent.newGeneration() + try agent.addObject(path: probe.path) + #expect(try agent.runOnMain(symbol: "preview_window_probe") == 1) + } + + @Test func bridgeAppliesWindowSpecOnCreation() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("window-spec-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + try """ + import SwiftUI + + #Preview { + Color.green.frame(width: 8, height: 8) + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + let build = try await session.compileObjectForJIT( + window: JITRenderWindow( + x: -9000, y: -9000, width: 320, height: 240, + title: "Preview: ColorView.swift")) + + let probe = try await compiler.compileObject( + source: """ + import AppKit + + @_cdecl("preview_window_spec_probe") + public func preview_window_spec_probe() -> Int32 { + MainActor.assumeIsolated { + guard + let window = NSApplication.shared.windows.first(where: { + $0.identifier?.rawValue == "previewsmcp-preview" + }) + else { return -1 } + var bits: Int32 = 0 + if window.title == "Preview: ColorView.swift" { bits |= 1 } + if abs(window.frame.width - 320) < 1 { bits |= 2 } + if let content = window.contentView, + abs(content.bounds.height - 240) < 1 + { + bits |= 4 + } + if window.styleMask.contains(.titled) { bits |= 8 } + return bits + } + } + """, + moduleName: "WindowSpecProbeFixture" + ) + + let agent = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try agent.addObject(path: build.objectPath.path) + #expect(try agent.runOnMain(symbol: build.entrySymbol) == 0) + try agent.newGeneration() + try agent.addObject(path: probe.path) + let bits = try agent.runOnMain(symbol: "preview_window_spec_probe") + #expect(bits == 15, "bits=\(bits)") + } + @Test func reusesOneSessionAcrossFreshGenerations() async throws { let compiler = try await Compiler() let colors = [(1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 0, 0), (0, 1, 0)] diff --git a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift index 9fd33e66..0f79a8b9 100644 --- a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift +++ b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift @@ -79,6 +79,44 @@ struct ExamplesSplitE2ETests { #expect(NSBitmapImageRep(data: png) != nil) } + @Test func splitRendersSetupWrappedPreviewInAgent() async throws { + let hot = Self.spmRoot.appendingPathComponent("Sources/ToDo/Summary.swift") + let ctx = try await Self.context(for: hot) + let configResult = try #require( + ProjectConfigLoader.find(from: hot.deletingLastPathComponent())) + let setupConfig = try #require(configResult.config.setup) + let setup = try await SetupBuilder.build( + config: setupConfig, configDirectory: configResult.directory, platform: .macOS) + + let compiler = try await Compiler() + let session = PreviewSession( + sourceFile: hot, compiler: compiler, buildContext: ctx, + setupModule: setup.moduleName, setupType: setup.typeName, + setupCompilerFlags: setup.compilerFlags, setupSDKPath: setup.sdkPath, + setupDylibPath: setup.dylibPath) + + let build = try await session.compileObjectForJIT() + #expect(build.setupEntrySymbol == "previewSetUp") + #expect(build.dylibPaths.contains(setup.dylibPath)) + + let reloader = JITStructuralReloader() + try await reloader.render(build) + let rep = try #require(NSBitmapImageRep(data: Data(contentsOf: build.imagePath))) + + var sawBadge = false + for y in 0.. 0.6, color.greenComponent < 0.35, + color.redComponent > 0.2, color.redComponent < 0.6 + { + sawBadge = true + } + } + } + #expect(sawBadge, "setup plugin banner not found in agent render") + } + /// Capped-persistent reuse with the FULL dependency closure: render the same real preview /// twice through one reloader, so generation 2 re-links ToDoExtras / Lottie / the builtins /// archive into a fresh JITDylib. Guards against duplicate Swift-metadata registration diff --git a/Tests/PreviewsJITLinkTests/Fixtures/event_loop_probe.swift b/Tests/PreviewsJITLinkTests/Fixtures/event_loop_probe.swift new file mode 100644 index 00000000..b6c33a2e --- /dev/null +++ b/Tests/PreviewsJITLinkTests/Fixtures/event_loop_probe.swift @@ -0,0 +1,23 @@ +import AppKit + +nonisolated(unsafe) var observedAppDefinedEvent = false + +@_cdecl("event_pump_install") +public func event_pump_install() -> Int32 { + NSEvent.addLocalMonitorForEvents(matching: .applicationDefined) { event in + observedAppDefinedEvent = true + return event + } + guard + let event = NSEvent.otherEvent( + with: .applicationDefined, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: 0, context: nil, subtype: 0, data1: 0, data2: 0) + else { return -1 } + NSApplication.shared.postEvent(event, atStart: false) + return 1 +} + +@_cdecl("event_pump_check") +public func event_pump_check() -> Int32 { + observedAppDefinedEvent ? 1 : 0 +} diff --git a/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift index 685c5dfe..4e86153b 100644 --- a/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift @@ -99,6 +99,19 @@ struct PreviewsJITLinkTests { #expect(result == 7) } + @Test func agentDispatchesAppKitEvents() throws { + let object = try FixtureSupport.compile("event_loop_probe.swift") + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addObject(path: object.path) + #expect(try session.runOnMain(symbol: "event_pump_install") == 1) + var observed: Int32 = 0 + for _ in 0..<20 where observed != 1 { + Thread.sleep(forTimeInterval: 0.1) + observed = try session.runOnMain(symbol: "event_pump_check") + } + #expect(observed == 1) + } + @Test func buildsHostingViewOnMainThreadRemotely() throws { let object = try FixtureSupport.compile("hosting_probe.swift") let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index b9cb1cc6..cb05e27e 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -909,3 +909,74 @@ view (leaf assumption) in the `examples/` projects; (2) where the daemon current gets `buildContext.sourceFiles` and whether the edited file is reliably in that set; (3) the existing `compileObjectForJIT` standalone path stays as the no-build- context fallback (stable module = boilerplate or skip split). + +## Update 2026-06-09 (evening): agent-render fixes landed, #194/#193 merged + +Running the full local merge bar (the `integration-test` skill) against the +non-leaf branch surfaced two agent-render bugs that `main` shared, both fixed +in PR #194 alongside the non-leaf split: + +- **`ImageRenderer` cannot rasterize AppKit-backed SwiftUI.** Nil `cgImage` + with `NavigationStack { List }` at the root (agent entry status -1, hit by + any cross-file edit that was a session's first structural reload), tiny + blank raster otherwise. `renderPreviewToFile` now hosts the view in a + borderless off-screen `NSWindow` via `NSHostingView` and captures at 1x like + `Snapshot.capture`. Guarded by + `ExamplesSplitE2ETests.nonLeafRendersUneditedPreviewInAgent`. +- **Stale agent PNG shadowed the window.** `snapshot()` prefers the agent PNG + once a session is agent-backed, but the dylib window paths + (`preview_switch`, `preview_configure`) never cleared it. `loadPreview` now + drops the session's `agentImagePaths` entry. + +Both slipped through because existing assertions only checked "snapshot +changed" / "PNG non-empty", and the session-start render goes through the +dylib window, so the agent had never rendered an unedited body. + +Merge bar at merge time: JIT 51/51, touched-module units 320/320, CLI command +suites 46/46, MCP macOS 7/7, `integration-test` macOS scope green across all +four examples, daemon log clean. PR #194 and PR #193 are squash-merged +(`main` = `907764c`); stale branches and worktrees are cleaned. Only +`previews-research` remains as a long-lived reference branch. + +**Next step: single-renderer consolidation.** The stale-PNG bug is a symptom +of a session having two renderers (dylib window + agent). Route session +start, `preview_switch`, and `preview_configure` through the agent render +path so the agent is the single source of truth, demoting the window to a +viewer of agent output. Design and subproblems tracked on the +`jit-single-renderer` branch. + +## Update 2026-06-10: single-renderer PR #196, review outcome, multi-session next + +Branch `jit-single-renderer` (PR #196) landed consolidation steps 1-3: the +agent hosts one persistent window (found by identifier, content swapped per +generation), the daemon bakes its window frame/title into the bridge and +orders its dylib window out after the first agent render, the agent main +thread runs the real AppKit event loop (window is interactive), switch / +configure / variants route through the agent for agent-backed sessions via +one `reload(jit:dylib:)` seam, and the setup plugin (wrap + `previewSetUp` + +dylib + SDK override) carries through `compileObjectForJIT` — fixing a bug +`main` had where structural reloads silently stripped the plugin. + +A subagent code review of the PR confirmed and we fixed on-branch: shared +setup validation (`BridgeGenerator.isUsableSetup`), stable-module SDK +consistency + the issue-#170 guard in all compile paths +(`Compiler.resolveSDK`), control-character escaping for generated string +literals, an Aqua-session guard before `[NSApp run]`, single bridge +generation per edit, and shared mutation helpers so dylib/JIT semantics +cannot diverge. + +Deferred with tracking issues: +- **#195** — frame handover across agent respawns (observer-sidecar design). +- **#197** — multi-session: shared agent window identity, zombie window on + stop, per-process `didRunSetUp`, shared `lastObjectPath`. Design into the + next phases, not piecemeal. + +**Remaining consolidation order:** (1) session start through the agent — no +dylib compile at start, agent window is the session surface from the first +render; this step must pick the multi-session topology (#197). (2) Retire +the dylib machinery for JIT builds (loaders, dlsym literal setters, +`agentImagePaths` dance). Then the broader roadmap: latency (<200ms, +non-leaf respawn/double-compile/sticky-latch), agent render size from +session size (400x600 is baked today), content-based test assertions, dylib +fallback decision, iOS JIT design, local merge queue (no GH issue exists +yet; user may want one).