From 91284bcca0b05f6a3e1df16a19dee0a6cff87869 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 9 Jun 2026 20:27:24 -0400 Subject: [PATCH 1/8] Docs: record agent-render fixes, merge-bar results, and consolidation pointer Co-Authored-By: Claude Fable 5 --- docs/jit-executor-phase3-plan.md | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index b9cb1cc6..38b9c9b6 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -909,3 +909,38 @@ 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. From dd9eeac746b09f31d222ea5abd4d541b024d272f Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 9 Jun 2026 20:57:25 -0400 Subject: [PATCH 2/8] JIT: persist one preview window in the agent across generations First step of single-renderer consolidation. The render entry created a fresh off-screen NSWindow per call and ordered it out afterward, so the agent had no live surface to keep between edits. The entry now looks the window up by identifier in the agent's window list and swaps its content view, creating it only on the first render. AppKit state is process-wide, so the window survives JITDylib generations; later steps point session start, switch, and configure at this window and retire the daemon's dylib window for JIT builds. bridgeReusesPreviewWindowAcrossGenerations renders two real bridge builds through one agent and probes from a third generation that exactly one identified window exists, with each render's pixels reflecting its own generation. Co-Authored-By: Claude Fable 5 --- Sources/PreviewsCore/BridgeGenerator.swift | 23 ++++++-- .../CappedPersistentTests.swift | 57 +++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/Sources/PreviewsCore/BridgeGenerator.swift b/Sources/PreviewsCore/BridgeGenerator.swift index a442db1d..964c6e4d 100644 --- a/Sources/PreviewsCore/BridgeGenerator.swift +++ b/Sources/PreviewsCore/BridgeGenerator.swift @@ -265,6 +265,11 @@ 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? ) -> String { @@ -286,15 +291,23 @@ public enum BridgeGenerator { \(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 } + ?? { + let created = NSWindow( + contentRect: bounds, styleMask: [.borderless], + backing: .buffered, defer: false) + created.identifier = identifier + created.isReleasedWhenClosed = false + created.setFrameOrigin(NSPoint(x: -10_000, y: -10_000)) + return created + }() + window.setContentSize(bounds.size) let hosting = NSHostingView(rootView: view) window.contentView = hosting - window.setFrameOrigin(NSPoint(x: -10_000, y: -10_000)) window.orderFrontRegardless() hosting.layoutSubtreeIfNeeded() - defer { window.orderOut(nil) } guard let rep = NSBitmapImageRep( bitmapDataPlanes: nil, diff --git a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift index 4f7ac6ac..22a6dcea 100644 --- a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift +++ b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift @@ -101,6 +101,63 @@ 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 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)] From 5bdb2461bd29ff70403e540a0a1ef2546e1d8cd0 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 9 Jun 2026 21:27:52 -0400 Subject: [PATCH 3/8] JIT: show the agent's live window on screen, hand off from the daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second step of single-renderer consolidation. An optional JITRenderWindow (frame + title) is baked into the render entry: when present the agent creates its persistent window titled and on screen at the daemon window's frame instead of borderless off-screen, with sizingOptions cleared so SwiftUI's ideal size cannot resize it away from the requested frame. The spec applies only at window creation, so a user's drag or resize survives leaf edits. On a visible session the daemon bakes its window's frame into the structural reload and orders its own dylib window out after the first successful agent render — the agent window is the interactive surface from then on. Headless sessions and tests pass no spec and keep the off-screen behavior. bridgeAppliesWindowSpecOnCreation drives a real bridge build through the agent and probes title, content size, and titled style from a second generation. Origin is not asserted: AppKit constrains titled windows onto a screen, which is the desired behavior for real frames. Co-Authored-By: Claude Fable 5 --- Sources/PreviewsCore/BridgeGenerator.swift | 66 +++++++++++++++---- Sources/PreviewsCore/PreviewSession.swift | 8 ++- Sources/PreviewsMacOS/HostApp.swift | 18 ++++- .../CappedPersistentTests.swift | 59 +++++++++++++++++ 4 files changed, 135 insertions(+), 16 deletions(-) diff --git a/Sources/PreviewsCore/BridgeGenerator.swift b/Sources/PreviewsCore/BridgeGenerator.swift index 964c6e4d..5f126cf4 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) @@ -59,7 +80,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 { @@ -271,7 +293,7 @@ public enum BridgeGenerator { /// 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 { @@ -284,35 +306,55 @@ public enum BridgeGenerator { } """ } ?? "" + let createWindow: String + if let window { + let title = window.title + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + 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 identifier = NSUserInterfaceItemIdentifier("previewsmcp-preview") let window = NSApplication.shared.windows.first { $0.identifier == identifier } ?? { - let created = NSWindow( - contentRect: bounds, styleMask: [.borderless], - backing: .buffered, defer: false) + \(createWindow) created.identifier = identifier created.isReleasedWhenClosed = false - created.setFrameOrigin(NSPoint(x: -10_000, y: -10_000)) return created }() - window.setContentSize(bounds.size) let hosting = NSHostingView(rootView: view) + hosting.sizingOptions = [] window.contentView = hosting window.orderFrontRegardless() hosting.layoutSubtreeIfNeeded() - 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, diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 2f544aed..72458379 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -206,7 +206,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) @@ -237,7 +237,8 @@ public actor PreviewSession { traits: traits, renderOutputPath: imagePath.path, designTimeValuesPath: valuesPath.path, - stableModuleImport: splitContext?.0.moduleName + stableModuleImport: splitContext?.0.moduleName, + renderWindow: window ) let objectPath: URL @@ -271,7 +272,8 @@ public actor PreviewSession { traits: traits, renderOutputPath: imagePath.path, designTimeValuesPath: valuesPath.path, - stableModuleImport: nil + stableModuleImport: nil, + renderWindow: window ) let built = try await compiler.compileModuleIncremental( overlaySource: overlay.source, diff --git a/Sources/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index b804e8cd..1c9924c8 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -356,12 +356,28 @@ 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() + let spec = 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 + ) + } + let build = try await session.compileObjectForJIT(window: spec) try await reloader.render(build) agentImagePaths[sessionID] = build.imagePath + if spec != nil { windows[sessionID]?.orderOut(nil) } return build.imagePath } diff --git a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift index 22a6dcea..04cceea3 100644 --- a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift +++ b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift @@ -158,6 +158,65 @@ struct CappedPersistentTests { #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)] From 10d4a4909d366c54a1544310cddf022ee3be688c Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 9 Jun 2026 21:37:05 -0400 Subject: [PATCH 4/8] Agent: run the AppKit event loop so the preview window is interactive The agent's main thread spun CFRunLoopRunInMode, which drains the main dispatch queue and draws but never dequeues window-server events, so the agent's on-screen window painted without responding to clicks. The tail of main now starts -[NSApplication run] via the ObjC runtime (AppKit is already dlopen'd at startup), with the CF spin kept as the no-AppKit fallback. run_on_main's dispatch_sync target still drains under the event loop. agentDispatchesAppKitEvents guards it: a local monitor plus a posted applicationDefined event only observes delivery when the event loop is actually pumping. Co-Authored-By: Claude Fable 5 --- Sources/PreviewAgent/main.cpp | 19 +++++++++++++++ .../Fixtures/event_loop_probe.swift | 23 +++++++++++++++++++ .../PreviewsJITLinkTests.swift | 13 +++++++++++ 3 files changed, 55 insertions(+) create mode 100644 Tests/PreviewsJITLinkTests/Fixtures/event_loop_probe.swift diff --git a/Sources/PreviewAgent/main.cpp b/Sources/PreviewAgent/main.cpp index 8b109810..a8e2d353 100644 --- a/Sources/PreviewAgent/main.cpp +++ b/Sources/PreviewAgent/main.cpp @@ -267,6 +267,25 @@ 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. + 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 is unavailable: keep servicing the main queue. auto *RunInMode = reinterpret_cast( dlsym(RTLD_DEFAULT, "CFRunLoopRunInMode")); 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()) From a8ce35189324817a4492972ca27c157730a0f493 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 9 Jun 2026 21:56:51 -0400 Subject: [PATCH 5/8] Engine: route switch and configure through the agent for agent-backed sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third step of single-renderer consolidation. Once a session hands its window to the agent, preview_switch / preview_configure / variants' setTraits went down the dylib path: recompile, loadPreview into the daemon's ordered-out window, agentImagePaths cleared — resurrecting the dead window as a second, stale surface and flipping snapshots away from the agent. MacOSPreviewHandle now detects the agent-backed state (reloader present + agent snapshot recorded) and re-renders through the agent instead: PreviewSession gains JIT counterparts of switchPreview / reconfigure / setTraits (same mutation and rollback semantics, compiled via compileObjectForJIT), and PreviewHost splits its reload into agentWindowSpec(for:) + jitRender(sessionID:build:) so the handle can compose them. Non-agent sessions and non-JIT builds keep the dylib path unchanged. Covered by agentBackedSwitchAndConfigureRouteThroughReloader: switch and reconfigure on an agent-backed handle render through the reloader, keep the agent PNG authoritative, reject an invalid index without a render, and switch cleanly afterward. Co-Authored-By: Claude Fable 5 --- Sources/PreviewsCore/PreviewSession.swift | 33 ++++++++++++ .../PreviewsEngine/MacOSPreviewHandle.swift | 32 ++++++++++++ Sources/PreviewsMacOS/HostApp.swift | 24 +++++++-- ...MacOSPreviewHandleAgentSnapshotTests.swift | 51 ++++++++++++++++++- 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 72458379..8624618c 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -524,6 +524,39 @@ 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 { + let oldIndex = previewIndex + previewIndex = newIndex + do { + return try await compileObjectForJIT(window: window) + } catch { + previewIndex = oldIndex + throw error + } + } + + /// 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 { + self.traits = self.traits.merged(with: 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..2eb19fcb 100644 --- a/Sources/PreviewsEngine/MacOSPreviewHandle.swift +++ b/Sources/PreviewsEngine/MacOSPreviewHandle.swift @@ -39,6 +39,11 @@ 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 { + if let route = await agentRoute() { + let build = try await session.setTraitsForJIT(traits, window: route.window) + try await host.jitRender(sessionID: id, build: build) + return + } let result = try await session.setTraits(traits) try await MainActor.run { try host.loadPreview(sessionID: id, dylibPath: result.dylibPath) @@ -46,6 +51,12 @@ public actor MacOSPreviewHandle: PreviewSessionHandle { } public func reconfigure(traits: PreviewTraits, clearing: Set) async throws { + if let route = await agentRoute() { + let build = try await session.reconfigureForJIT( + traits: traits, clearing: clearing, window: route.window) + try await host.jitRender(sessionID: id, build: build) + return + } let result = try await session.reconfigure(traits: traits, clearing: clearing) try await MainActor.run { try host.loadPreview(sessionID: id, dylibPath: result.dylibPath) @@ -53,12 +64,33 @@ public actor MacOSPreviewHandle: PreviewSessionHandle { } public func switchPreview(to index: Int) async throws { + if let route = await agentRoute() { + let build = try await session.switchPreviewForJIT(to: index, window: route.window) + try await host.jitRender(sessionID: id, build: build) + return + } let result = try await session.switchPreview(to: index) try await MainActor.run { try host.loadPreview(sessionID: id, dylibPath: result.dylibPath) } } + /// Non-nil when the session is agent-backed and a JIT reloader is present, so + /// switch/configure must re-render in the agent instead of reloading a dylib + /// into the daemon window (which would resurrect it as a second, stale surface). + private struct AgentRoute { + let window: JITRenderWindow? + } + + private func agentRoute() async -> AgentRoute? { + await MainActor.run { + guard host.structuralReloader != nil, host.agentSnapshotPath(for: id) != nil else { + return nil + } + return AgentRoute(window: host.agentWindowSpec(for: id)) + } + } + public func snapshot(quality: Double) async throws -> Data { let format: Snapshot.ImageFormat = quality >= 1.0 ? .png : .jpeg(quality: quality) let sessionID = id diff --git a/Sources/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index 1c9924c8..e6d3817d 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -363,8 +363,15 @@ public class PreviewHost: NSObject, NSApplicationDelegate { /// 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 spec = windows[sessionID].flatMap { window -> JITRenderWindow? in + 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, @@ -374,10 +381,19 @@ public class PreviewHost: NSObject, NSApplicationDelegate { title: window.title ) } - let build = try await session.compileObjectForJIT(window: spec) + } + + /// 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 - if spec != nil { windows[sessionID]?.orderOut(nil) } + windows[sessionID]?.orderOut(nil) return build.imagePath } 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( From c0a1a258e39b62e56958866335bad6aa0e046227 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 10 Jun 2026 06:01:15 -0400 Subject: [PATCH 6/8] JIT: carry the setup plugin through the agent render path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every structural reload silently dropped the setup plugin: the agent render compiled without the setup wrap, never ran previewSetUp, and never loaded the setup dylib, so plugin-injected UI and state vanished the moment a session went agent-backed (on main too). compileObjectForJIT now passes setupModule/setupType into the bridge (wrap + previewSetUp entry generated), appends the setup compiler flags, inherits the setup SDK override (the issue #170 guard, added to compileObject and compileModuleIncremental), prepends the setup dylib to the build's dylibPaths, and reports the setup entry on JITRenderBuild. JITStructuralReloader runs that entry once per agent process — re-run after a respawn, skipped per generation and on literal re-renders. PreviewStartHandler forwards the setup dylib path into the session. splitRendersSetupWrappedPreviewInAgent renders the SPM example's Summary preview with the real ToDoPreviewSetup plugin through the reloader and asserts the plugin banner's pixels appear in the agent PNG. Co-Authored-By: Claude Fable 5 --- .../Handlers/PreviewStartHandler.swift | 3 +- Sources/PreviewsCore/Compiler.swift | 10 +++-- Sources/PreviewsCore/PreviewSession.swift | 29 ++++++++++++-- .../JITStructuralReloader.swift | 9 +++++ .../ExamplesSplitE2ETests.swift | 38 +++++++++++++++++++ 5 files changed, 80 insertions(+), 9 deletions(-) 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/Compiler.swift b/Sources/PreviewsCore/Compiler.swift index c9d2cf55..7e556bd0 100644 --- a/Sources/PreviewsCore/Compiler.swift +++ b/Sources/PreviewsCore/Compiler.swift @@ -151,7 +151,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 +166,7 @@ public actor Compiler { "-emit-object", "-parse-as-library", "-target", targetTriple, - "-sdk", sdkPath, + "-sdk", overrideSDK ?? sdkPath, "-module-name", moduleName, "-Onone", "-gnone", @@ -274,7 +275,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 +319,7 @@ public actor Compiler { "-emit-object", "-parse-as-library", "-target", targetTriple, - "-sdk", sdkPath, + "-sdk", overrideSDK ?? sdkPath, "-module-name", moduleName, "-Onone", "-gnone", diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 8624618c..14e00325 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. @@ -229,12 +237,15 @@ public actor PreviewSession { return (ctx, bulk) } + let hasSetup = splitContext != nil && setupModule != nil && setupType != nil 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, @@ -253,12 +264,18 @@ public actor PreviewSession { } dylibPaths = Self.dependencyDylibs(in: ctx.compilerFlags) + if let path = setupDylibPath, hasSetup { + dylibPaths.insert(path, at: 0) + } + if let stable = try await stableModuleIfLeaf(for: bulk, context: ctx) { 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 @@ -270,6 +287,8 @@ public actor PreviewSession { previewIndex: previewIndex, platform: platform, traits: traits, + setupModule: hasSetup ? setupModule : nil, + setupType: hasSetup ? setupType : nil, renderOutputPath: imagePath.path, designTimeValuesPath: valuesPath.path, stableModuleImport: nil, @@ -279,7 +298,8 @@ public actor PreviewSession { overlaySource: overlay.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) @@ -305,7 +325,8 @@ public actor PreviewSession { supportObjectPaths: supportObjectPaths, archivePaths: archivePaths, dylibPaths: dylibPaths, - requiresFreshAgent: requiresFreshAgent + requiresFreshAgent: requiresFreshAgent, + setupEntrySymbol: hasSetup ? "previewSetUp" : nil ) lastJITBuild = build return build 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/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 From 88d29ff3781bd460171bd96336c67486a8897a14 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 10 Jun 2026 06:57:19 -0400 Subject: [PATCH 7/8] Review fixes: setup guards, SDK consistency, escaping, routing seam From the PR #196 code review: - Setup validation is one predicate (BridgeGenerator.isUsableSetup), so a build can no longer advertise a previewSetUp entry the generated source omitted (non-identifier module/type names made every reload fail symbol-not-found). - The stable module compiles under the same SDK as the editable overlay (emitStableModule gains overrideSDK), and the issue-#170 stale-SDK guard now covers compileObject and compileModuleIncremental via a shared Compiler.resolveSDK. - Generated string literals escape control characters as well as quotes/backslashes (a newline-bearing window title or path no longer breaks the bridge compile), applied to title and both baked paths. - The agent only runs -[NSApplication run] when an Aqua session is reachable (CGSessionCopyCurrentDictionary), keeping the CFRunLoop fallback live for SSH/launchd contexts. - compileObjectForJIT generates the bridge exactly once (the non-leaf path no longer builds a discarded twin, removing the literals-vs-compiled-source divergence risk and a duplicate source transform per edit). - MacOSPreviewHandle routes all mutating reloads through one reload(jit:dylib:) seam, and PreviewSession's dylib/JIT twins share withPreviewIndex/applyReconfigure so rollback and merge semantics cannot diverge. Co-Authored-By: Claude Fable 5 --- Sources/PreviewAgent/main.cpp | 32 +++++---- Sources/PreviewsCore/BridgeGenerator.swift | 42 ++++++++--- Sources/PreviewsCore/Compiler.swift | 71 +++++++++--------- Sources/PreviewsCore/PreviewSession.swift | 64 +++++++++-------- .../PreviewsEngine/MacOSPreviewHandle.swift | 72 +++++++++---------- .../BridgeGeneratorSetupGuardTests.swift | 39 ++++++++++ 6 files changed, 194 insertions(+), 126 deletions(-) create mode 100644 Tests/PreviewsCoreTests/BridgeGeneratorSetupGuardTests.swift diff --git a/Sources/PreviewAgent/main.cpp b/Sources/PreviewAgent/main.cpp index a8e2d353..3b899dec 100644 --- a/Sources/PreviewAgent/main.cpp +++ b/Sources/PreviewAgent/main.cpp @@ -271,21 +271,29 @@ int main(int argc, char *argv[]) { // 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. - 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 + // 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 is unavailable: keep servicing the main queue. + // 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/PreviewsCore/BridgeGenerator.swift b/Sources/PreviewsCore/BridgeGenerator.swift index 5f126cf4..55585050 100644 --- a/Sources/PreviewsCore/BridgeGenerator.swift +++ b/Sources/PreviewsCore/BridgeGenerator.swift @@ -60,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 = @@ -169,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 = @@ -264,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, @@ -298,7 +320,7 @@ public enum BridgeGenerator { 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] { @@ -308,9 +330,7 @@ public enum BridgeGenerator { } ?? "" let createWindow: String if let window { - let title = window.title - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") + let title = escapedForSwiftStringLiteral(window.title) createWindow = """ NSApplication.shared.setActivationPolicy(.accessory) let created = NSWindow( @@ -370,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 7e556bd0..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) " @@ -166,7 +142,7 @@ public actor Compiler { "-emit-object", "-parse-as-library", "-target", targetTriple, - "-sdk", overrideSDK ?? sdkPath, + "-sdk", try resolveSDK(overrideSDK), "-module-name", moduleName, "-Onone", "-gnone", @@ -194,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( @@ -210,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 @@ -218,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( @@ -226,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") @@ -245,7 +224,7 @@ public actor Compiler { "-parse-as-library", "-enable-testing", "-target", targetTriple, - "-sdk", sdkPath, + "-sdk", try resolveSDK(overrideSDK), "-module-name", moduleName, "-Onone", "-gnone", @@ -319,7 +298,7 @@ public actor Compiler { "-emit-object", "-parse-as-library", "-target", targetTriple, - "-sdk", overrideSDK ?? sdkPath, + "-sdk", try resolveSDK(overrideSDK), "-module-name", moduleName, "-Onone", "-gnone", @@ -336,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 14e00325..79fe7e76 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -237,7 +237,15 @@ public actor PreviewSession { return (ctx, bulk) } - let hasSetup = splitContext != nil && setupModule != nil && setupType != nil + 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, @@ -248,7 +256,7 @@ public actor PreviewSession { setupType: hasSetup ? setupType : nil, renderOutputPath: imagePath.path, designTimeValuesPath: valuesPath.path, - stableModuleImport: splitContext?.0.moduleName, + stableModuleImport: stable != nil ? splitContext?.0.moduleName : nil, renderWindow: window ) @@ -268,7 +276,7 @@ public actor PreviewSession { dylibPaths.insert(path, at: 0) } - if let stable = try await stableModuleIfLeaf(for: bulk, context: ctx) { + if let stable { supportObjectPaths = [stable.objectPath] objectPath = try await compiler.compileObject( source: generated.source, @@ -281,21 +289,8 @@ public actor PreviewSession { // 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, - setupModule: hasSetup ? setupModule : nil, - setupType: hasSetup ? setupType : nil, - renderOutputPath: imagePath.path, - designTimeValuesPath: valuesPath.path, - stableModuleImport: nil, - renderWindow: window - ) let built = try await compiler.compileModuleIncremental( - overlaySource: overlay.source, + overlaySource: generated.source, bulkFiles: bulk, moduleName: ctx.moduleName, extraFlags: ctx.compilerFlags + setupCompilerFlags, @@ -438,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 @@ -512,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: @@ -534,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() } @@ -550,14 +559,7 @@ public actor PreviewSession { public func switchPreviewForJIT( to newIndex: Int, window: JITRenderWindow? = nil ) async throws -> JITRenderBuild { - let oldIndex = previewIndex - previewIndex = newIndex - do { - return try await compileObjectForJIT(window: window) - } catch { - previewIndex = oldIndex - throw error - } + try await withPreviewIndex(newIndex) { try await compileObjectForJIT(window: window) } } /// JIT counterpart of `reconfigure`: merge-and-clear traits, compile for the agent. @@ -566,7 +568,7 @@ public actor PreviewSession { clearing: Set = [], window: JITRenderWindow? = nil ) async throws -> JITRenderBuild { - self.traits = self.traits.merged(with: traits).clearing(clearing) + applyReconfigure(traits: traits, clearing: clearing) return try await compileObjectForJIT(window: window) } diff --git a/Sources/PreviewsEngine/MacOSPreviewHandle.swift b/Sources/PreviewsEngine/MacOSPreviewHandle.swift index 2eb19fcb..5eb9b9f4 100644 --- a/Sources/PreviewsEngine/MacOSPreviewHandle.swift +++ b/Sources/PreviewsEngine/MacOSPreviewHandle.swift @@ -39,55 +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 { - if let route = await agentRoute() { - let build = try await session.setTraitsForJIT(traits, window: route.window) - try await host.jitRender(sessionID: id, build: build) - return - } - 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 { - if let route = await agentRoute() { - let build = try await session.reconfigureForJIT( - traits: traits, clearing: clearing, window: route.window) - try await host.jitRender(sessionID: id, build: build) - return - } - 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 { - if let route = await agentRoute() { - let build = try await session.switchPreviewForJIT(to: index, window: route.window) - try await host.jitRender(sessionID: id, build: build) - return - } - let result = try await session.switchPreview(to: index) - try await MainActor.run { - try host.loadPreview(sessionID: id, dylibPath: result.dylibPath) - } - } - - /// Non-nil when the session is agent-backed and a JIT reloader is present, so - /// switch/configure must re-render in the agent instead of reloading a dylib - /// into the daemon window (which would resurrect it as a second, stale surface). - private struct AgentRoute { - let window: JITRenderWindow? + try await reload( + jit: { try await self.session.switchPreviewForJIT(to: index, window: $0) }, + dylib: { try await self.session.switchPreview(to: index) }) } - private func agentRoute() async -> AgentRoute? { - await MainActor.run { + /// 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 nil + 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) } - return AgentRoute(window: host.agentWindowSpec(for: id)) } } 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""#)) + } +} From 53fadac966b87492e6031d9981c5fa9aa05e3158 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 10 Jun 2026 06:58:05 -0400 Subject: [PATCH 8/8] Docs: record PR #196 review outcome and remaining consolidation order Co-Authored-By: Claude Fable 5 --- docs/jit-executor-phase3-plan.md | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 38b9c9b6..cb05e27e 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -944,3 +944,39 @@ 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).