From ce862f4eb7288904ec0854d6f2c5256e6090c3f8 Mon Sep 17 00:00:00 2001 From: "Md.Sadiq" Date: Sat, 2 May 2026 00:08:55 +0530 Subject: [PATCH 1/5] ios simulator LFG --- .../components/xero/emulator-missing-sdk.tsx | 110 +++++ client/src-tauri/Cargo.toml | 5 + client/src-tauri/build.rs | 131 ++++++ .../native/ios-helper/Connection.swift | 279 +++++++++++++ .../native/ios-helper/FrameCapture.swift | 180 +++++++++ .../native/ios-helper/HidBridge.swift | 350 ++++++++++++++++ .../native/ios-helper/JpegEncoder.swift | 71 ++++ client/src-tauri/native/ios-helper/Main.swift | 161 ++++++++ .../src/commands/emulator/ios/cg_input.rs | 17 + .../src/commands/emulator/ios/helper.rs | 169 ++++++++ .../commands/emulator/ios/helper_client.rs | 376 ++++++++++++++++++ .../src/commands/emulator/ios/mod.rs | 4 + .../src/commands/emulator/ios/session.rs | 137 ++++++- client/src-tauri/src/commands/emulator/mod.rs | 44 ++ client/src-tauri/src/commands/emulator/sdk.rs | 10 + .../src/commands/emulator/shutdown.rs | 6 +- client/src-tauri/src/lib.rs | 2 + client/src-tauri/tauri.conf.json | 3 +- 18 files changed, 2046 insertions(+), 9 deletions(-) create mode 100644 client/src-tauri/native/ios-helper/Connection.swift create mode 100644 client/src-tauri/native/ios-helper/FrameCapture.swift create mode 100644 client/src-tauri/native/ios-helper/HidBridge.swift create mode 100644 client/src-tauri/native/ios-helper/JpegEncoder.swift create mode 100644 client/src-tauri/native/ios-helper/Main.swift create mode 100644 client/src-tauri/src/commands/emulator/ios/helper.rs create mode 100644 client/src-tauri/src/commands/emulator/ios/helper_client.rs diff --git a/client/components/xero/emulator-missing-sdk.tsx b/client/components/xero/emulator-missing-sdk.tsx index 99f459af..82c7b04c 100644 --- a/client/components/xero/emulator-missing-sdk.tsx +++ b/client/components/xero/emulator-missing-sdk.tsx @@ -22,6 +22,8 @@ interface SdkStatus { idbCompanionPresent: boolean supported: boolean axPermissionGranted: boolean + screenRecordingPermissionGranted: boolean + helperPresent: boolean } } @@ -194,6 +196,15 @@ export function EmulatorMissingSdk({ active = true, platform, onDismiss }: Props ) } + // Screen Recording permission is needed for the Swift helper's + // ScreenCaptureKit frame capture. Show this card when the helper + // binary is present but permission hasn't been granted yet. + if (status.ios.present && status.ios.helperPresent && !status.ios.screenRecordingPermissionGranted) { + return ( + + ) + } + // AX-permission case takes precedence when Xcode is fine but macOS // hasn't granted us the Accessibility right. We render a dedicated // card because it needs invoke-backed action buttons, not just hrefs. @@ -323,6 +334,105 @@ function IosAxPermissionCard({ ) } +function IosScreenRecordingPermissionCard({ + isProbing, + onDismiss, + onProbe, +}: { + isProbing: boolean + onDismiss?: () => void + onProbe: () => void +}) { + const [busy, setBusy] = useState(false) + + // Poll while this banner is mounted so it disappears within a second + // or two of the user granting the permission. + useEffect(() => { + if (!isTauri()) return + const handle = window.setInterval(() => { + onProbe() + }, 1500) + return () => window.clearInterval(handle) + }, [onProbe]) + + const handlePrompt = useCallback(async () => { + if (!isTauri()) return + setBusy(true) + try { + await invoke("emulator_ios_request_screen_recording_permission") + } finally { + setBusy(false) + onProbe() + } + }, [onProbe]) + + const handleOpenSettings = useCallback(async () => { + if (!isTauri()) return + await invoke("emulator_ios_open_screen_recording_settings") + }, []) + + return ( +
+
Screen Recording permission needed
+
+ Xero captures the iOS Simulator window for a smooth preview using + ScreenCaptureKit — macOS requires Screen Recording permission for this. + Without it, Xero falls back to slower screenshot polling. Enable Xero in + System Settings → Privacy & Security → Screen Recording. +
+
+ + + + {onDismiss ? ( + + ) : null} +
+
+ ) +} + function errorMessage(err: unknown): string { if (err && typeof err === "object" && "message" in err) { const message = (err as { message?: unknown }).message diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index befc2253..93597a26 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -54,6 +54,11 @@ emulator-live = ["dep:openh264", "ios-grpc"] # and accessibility-tree/log RPCs; builds without this feature fall back to # a simctl-screenshot poll + AppleScript HID where possible. ios-grpc = ["dep:tonic", "dep:tonic-build", "dep:protoc-bin-vendored", "dep:prost", "dep:tokio-stream"] +# Enables compilation of the Swift ios-helper binary (xero-ios-helper) in +# build.rs. The helper uses ScreenCaptureKit for 30 FPS frame capture and +# IndigoHID for reliable input injection. Runtime detection works regardless +# of this feature — if the binary exists on disk, the session will use it. +ios-helper = [] [dependencies] arc-swap = "1" diff --git a/client/src-tauri/build.rs b/client/src-tauri/build.rs index 50dc7369..0d4009bd 100644 --- a/client/src-tauri/build.rs +++ b/client/src-tauri/build.rs @@ -22,6 +22,7 @@ fn main() { configure_custom_cfgs(); tauri_build::build(); compile_dictation_shim(); + compile_ios_helper(); build_cookie_importer(); fetch_scrcpy_server(); fetch_idb_companion(); @@ -151,6 +152,136 @@ fn compile_dictation_shim() { println!("cargo:rustc-cfg=xero_dictation_native_shim"); } +/// Compile the Swift helper binary (`xero-ios-helper`) that uses +/// ScreenCaptureKit for frame capture and IndigoHID for input injection. +/// The binary is a standalone executable (not a static library) that +/// communicates with the Tauri Rust backend over a Unix domain socket. +/// +/// Unlike `compile_dictation_shim()` which produces a `.a` linked into the +/// main binary, this produces an independent executable copied next to the +/// Tauri output binary. +#[cfg(target_os = "macos")] +fn compile_ios_helper() { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let helper_dir = manifest_dir.join("native/ios-helper"); + + println!("cargo:rerun-if-changed=native/ios-helper/Main.swift"); + println!("cargo:rerun-if-changed=native/ios-helper/Connection.swift"); + println!("cargo:rerun-if-changed=native/ios-helper/FrameCapture.swift"); + println!("cargo:rerun-if-changed=native/ios-helper/HidBridge.swift"); + println!("cargo:rerun-if-changed=native/ios-helper/JpegEncoder.swift"); + println!("cargo:rerun-if-env-changed=XERO_SKIP_IOS_HELPER"); + + if std::env::var_os("XERO_SKIP_IOS_HELPER").is_some() { + println!( + "cargo:warning=XERO_SKIP_IOS_HELPER is set; iOS helper binary will not be compiled." + ); + return; + } + + let Some(swiftc) = xcrun_find("swiftc") else { + println!( + "cargo:warning=swiftc not found via xcrun; iOS helper binary will not be compiled." + ); + return; + }; + let Some(sdk_path) = xcrun_output(&["--sdk", "macosx", "--show-sdk-path"]) else { + println!( + "cargo:warning=macOS SDK path not found; iOS helper binary will not be compiled." + ); + return; + }; + + // Check that ScreenCaptureKit is available (macOS 12.3+ SDK). + let sdk_version = xcrun_output(&["--sdk", "macosx", "--show-sdk-version"]).unwrap_or_default(); + let major: u32 = sdk_version + .split('.') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + if major < 12 { + println!( + "cargo:warning=macOS SDK {sdk_version} < 12.3; ScreenCaptureKit unavailable. \ + iOS helper binary will not be compiled." + ); + return; + } + + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let output = out_dir.join("xero-ios-helper"); + + let sources = [ + helper_dir.join("Main.swift"), + helper_dir.join("Connection.swift"), + helper_dir.join("FrameCapture.swift"), + helper_dir.join("HidBridge.swift"), + helper_dir.join("JpegEncoder.swift"), + ]; + + let status = Command::new(&swiftc) + .arg("-parse-as-library") + .arg("-module-name") + .arg("XeroIosHelper") + .arg("-sdk") + .arg(&sdk_path) + .arg("-O") + .arg("-framework") + .arg("ScreenCaptureKit") + .arg("-framework") + .arg("CoreGraphics") + .arg("-framework") + .arg("ImageIO") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("CoreMedia") + .arg("-o") + .arg(&output) + .args(sources.iter()) + .status() + .expect("failed to spawn swiftc for iOS helper"); + + if !status.success() { + // Non-fatal: the helper is optional. The session falls back to + // idb_companion / screenshot polling when the binary is absent. + println!( + "cargo:warning=failed to compile xero-ios-helper (exit {status:?}); \ + iOS Simulator will use fallback paths." + ); + return; + } + + // Copy the binary next to the Tauri output executable so it can be + // discovered by `helper::resolve_helper_binary()` during development. + // In bundled builds the binary is included via tauri.conf.json resources. + let profile_dir = out_dir + .ancestors() + .nth(3) + .expect("OUT_DIR should be inside target//build//out") + .to_path_buf(); + let destination = profile_dir.join("xero-ios-helper"); + if let Err(e) = std::fs::copy(&output, &destination) { + println!( + "cargo:warning=failed to copy xero-ios-helper to {}: {e}", + destination.display() + ); + } + + // Also copy to resources/ for Tauri bundling. + let resources_dir = manifest_dir.join("resources"); + let res_destination = resources_dir.join("xero-ios-helper"); + if let Err(e) = std::fs::copy(&output, &res_destination) { + println!( + "cargo:warning=failed to copy xero-ios-helper to resources/: {e}" + ); + } +} + +#[cfg(not(target_os = "macos"))] +fn compile_ios_helper() { + // No-op on non-macOS hosts. +} + fn xcrun_find(tool: &str) -> Option { xcrun_output(&["--find", tool]).map(PathBuf::from) } diff --git a/client/src-tauri/native/ios-helper/Connection.swift b/client/src-tauri/native/ios-helper/Connection.swift new file mode 100644 index 00000000..7c779d90 --- /dev/null +++ b/client/src-tauri/native/ios-helper/Connection.swift @@ -0,0 +1,279 @@ +// Connection.swift — UDS server + binary framing protocol. +// +// Framing: +// [1 byte type][4 bytes payload length BE][payload bytes] +// +// Types: +// 0x01 — JSON control message (UTF-8) +// 0x02 — Frame: payload = [4B width BE][4B height BE][JPEG bytes] + +import Foundation + +// MARK: - Error types + +enum HelperError: Error, CustomStringConvertible { + case invalidParams(String) + case unknownMethod(String) + case captureError(String) + case hidError(String) + case socketError(String) + case timeout + + var description: String { + switch self { + case .invalidParams(let m): return "invalid_params: \(m)" + case .unknownMethod(let m): return "unknown_method: \(m)" + case .captureError(let m): return "capture_error: \(m)" + case .hidError(let m): return "hid_error: \(m)" + case .socketError(let m): return "socket_error: \(m)" + case .timeout: return "timeout" + } + } + + var code: String { + switch self { + case .invalidParams: return "invalid_params" + case .unknownMethod: return "unknown_method" + case .captureError: return "capture_error" + case .hidError: return "hid_error" + case .socketError: return "socket_error" + case .timeout: return "timeout" + } + } +} + +// MARK: - Request/Response types + +struct HelperRequest { + let id: Int + let method: String + let params: [String: Any]? +} + +typealias ResponseCallback = (Result<[String: Any], HelperError>) -> Void + +// MARK: - Message types + +private let msgTypeJSON: UInt8 = 0x01 +private let msgTypeFrame: UInt8 = 0x02 + +// MARK: - Connection + +class Connection { + let socketPath: String + var onRequest: ((HelperRequest, @escaping ResponseCallback) -> Void)? + + private var serverFd: Int32 = -1 + private var clientFd: Int32 = -1 + private let writeLock = NSLock() + private var readThread: Thread? + private var alive = true + + init(socketPath: String) { + self.socketPath = socketPath + } + + // MARK: - Server lifecycle + + func acceptAndServe() { + // Remove stale socket file. + unlink(socketPath) + + serverFd = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFd >= 0 else { + fputs("failed to create socket: \(errno)\n", stderr) + exit(1) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + fputs("socket path too long\n", stderr) + exit(1) + } + withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in + pathBytes.withUnsafeBufferPointer { buf in + memcpy(sunPath, buf.baseAddress!, buf.count) + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + bind(serverFd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + fputs("failed to bind \(socketPath): \(errno)\n", stderr) + exit(1) + } + + guard listen(serverFd, 1) == 0 else { + fputs("failed to listen: \(errno)\n", stderr) + exit(1) + } + + // Accept one client (blocking). + clientFd = accept(serverFd, nil, nil) + guard clientFd >= 0 else { + fputs("accept failed: \(errno)\n", stderr) + exit(1) + } + + // Start background reader thread. + readThread = Thread { + self.readLoop() + } + readThread?.name = "xero-ios-helper-reader" + readThread?.start() + } + + func close() { + alive = false + if clientFd >= 0 { Darwin.close(clientFd); clientFd = -1 } + if serverFd >= 0 { Darwin.close(serverFd); serverFd = -1 } + unlink(socketPath) + } + + // MARK: - Read loop + + private func readLoop() { + while alive && clientFd >= 0 { + guard let (msgType, payload) = readMessage() else { + // Connection closed or error. + alive = false + break + } + + if msgType == msgTypeJSON { + handleJSONMessage(payload) + } + // Type 0x02 (frame) is outbound-only; ignore if received. + } + } + + private func handleJSONMessage(_ data: Data) { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = json["id"] as? Int, + let method = json["method"] as? String else { + return + } + + let params = json["params"] as? [String: Any] + let request = HelperRequest(id: id, method: method, params: params) + + onRequest?(request) { [weak self] result in + var response: [String: Any] = ["id": id] + switch result { + case .success(let payload): + response.merge(payload) { _, new in new } + case .failure(let error): + response["error"] = ["code": error.code, "message": error.description] + } + self?.sendJSON(response) + } + } + + // MARK: - Write methods + + func sendFrame(jpeg: Data, width: Int, height: Int) { + // Frame payload: [4B width BE][4B height BE][JPEG bytes] + var payload = Data(capacity: 8 + jpeg.count) + payload.appendBE(UInt32(width)) + payload.appendBE(UInt32(height)) + payload.append(jpeg) + writeMessage(type: msgTypeFrame, payload: payload) + } + + func sendJSON(_ object: [String: Any]) { + guard let data = try? JSONSerialization.data(withJSONObject: object) else { return } + writeMessage(type: msgTypeJSON, payload: data) + } + + func sendEvent(code: String, message: String) { + sendJSON(["event": "error", "code": code, "message": message]) + } + + // MARK: - Framing primitives + + private func writeMessage(type: UInt8, payload: Data) { + writeLock.lock() + defer { writeLock.unlock() } + + guard clientFd >= 0 else { return } + + // Header: [1B type][4B length BE] + var header = Data(capacity: 5) + header.append(type) + header.appendBE(UInt32(payload.count)) + + let headerOk = header.withUnsafeBytes { ptr in + writeAll(fd: clientFd, ptr.baseAddress!, ptr.count) + } + guard headerOk else { return } + + payload.withUnsafeBytes { ptr in + _ = writeAll(fd: clientFd, ptr.baseAddress!, ptr.count) + } + } + + private func readMessage() -> (UInt8, Data)? { + // Read header: 1 byte type + 4 bytes length. + guard let header = readExact(count: 5) else { return nil } + let msgType = header[0] + let length = header.readBE(at: 1) as UInt32 + + guard length <= 50_000_000 else { + // Sanity limit: 50MB max message. + fputs("message too large: \(length)\n", stderr) + return nil + } + + guard let payload = readExact(count: Int(length)) else { return nil } + return (msgType, payload) + } + + private func readExact(count: Int) -> Data? { + var buf = Data(count: count) + var offset = 0 + while offset < count { + let n = buf.withUnsafeMutableBytes { ptr in + read(clientFd, ptr.baseAddress!.advanced(by: offset), count - offset) + } + if n <= 0 { return nil } + offset += n + } + return buf + } + + @discardableResult + private func writeAll(fd: Int32, _ ptr: UnsafeRawPointer, _ count: Int) -> Bool { + var offset = 0 + while offset < count { + let n = write(fd, ptr.advanced(by: offset), count - offset) + if n <= 0 { + if errno == EAGAIN || errno == EWOULDBLOCK { continue } + return false + } + offset += n + } + return true + } +} + +// MARK: - Data extensions for big-endian encoding + +private extension Data { + mutating func appendBE(_ value: UInt32) { + var be = value.bigEndian + append(UnsafeBufferPointer(start: &be, count: 1)) + } + + func readBE(at offset: Int) -> UInt32 { + var value: UInt32 = 0 + _ = withUnsafeMutableBytes(of: &value) { dst in + copyBytes(to: dst, from: offset..<(offset + 4)) + } + return UInt32(bigEndian: value) + } +} diff --git a/client/src-tauri/native/ios-helper/FrameCapture.swift b/client/src-tauri/native/ios-helper/FrameCapture.swift new file mode 100644 index 00000000..f4273123 --- /dev/null +++ b/client/src-tauri/native/ios-helper/FrameCapture.swift @@ -0,0 +1,180 @@ +// FrameCapture.swift — ScreenCaptureKit frame capture. +// +// Captures the iOS Simulator window at the requested frame rate and +// delivers JPEG-encoded frames via the `onFrame` callback. + +import Foundation +import ScreenCaptureKit +import CoreMedia +import CoreGraphics + +@available(macOS 12.3, *) +class FrameCapture: NSObject, SCStreamOutput { + + let udid: String + var onFrame: ((Data, Int, Int) -> Void)? + var onError: ((String, String) -> Void)? + + private var stream: SCStream? + private var isCapturing = false + private let encoder = JpegEncoder() + + init(udid: String) { + self.udid = udid + super.init() + } + + // MARK: - Start / Stop + + struct Dimensions { + let width: Int + let height: Int + } + + func start(fps: Int, completion: @escaping (Result) -> Void) { + guard !isCapturing else { + completion(.failure(.captureError("already capturing"))) + return + } + + findSimulatorWindow { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let err): + completion(.failure(err)) + case .success(let window): + self.startStream(window: window, fps: fps, completion: completion) + } + } + } + + func stop() { + guard isCapturing, let stream = stream else { return } + isCapturing = false + stream.stopCapture { error in + if let error = error { + fputs("stop capture error: \(error)\n", stderr) + } + } + self.stream = nil + } + + // MARK: - Window discovery + + private func findSimulatorWindow( + completion: @escaping (Result) -> Void + ) { + SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { [weak self] content, error in + guard let self = self else { return } + + if let error = error { + let nsError = error as NSError + // Code 1 typically means Screen Recording permission denied. + if nsError.domain == "com.apple.ScreenCaptureKit.SCStreamErrorDomain" { + self.onError?("screen_recording_denied", "Screen Recording permission required") + completion(.failure(.captureError("screen_recording_denied"))) + return + } + completion(.failure(.captureError(error.localizedDescription))) + return + } + + guard let content = content else { + completion(.failure(.captureError("no shareable content"))) + return + } + + // Find the Simulator app. + let simApp = content.applications.first { app in + app.bundleIdentifier == "com.apple.iphonesimulator" + } + + guard let simApp = simApp else { + completion(.failure(.captureError("Simulator.app not running"))) + return + } + + // Find the window whose title contains the device name. + // Simulator window titles look like "iPhone 17 Pro — iOS 26.0". + // We match against the UDID device name resolved by the Rust side. + let simWindows = content.windows.filter { window in + window.owningApplication?.bundleIdentifier == simApp.bundleIdentifier + && window.isOnScreen + } + + // Prefer the largest on-screen window (the device viewport). + let window = simWindows.max(by: { a, b in + (a.frame.width * a.frame.height) < (b.frame.width * b.frame.height) + }) + + guard let window = window else { + completion(.failure(.captureError("no Simulator window found for \(self.udid)"))) + return + } + + completion(.success(window)) + } + } + + // MARK: - Stream setup + + private func startStream( + window: SCWindow, + fps: Int, + completion: @escaping (Result) -> Void + ) { + let filter = SCContentFilter(desktopIndependentWindow: window) + let config = SCStreamConfiguration() + config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps)) + config.pixelFormat = kCVPixelFormatType_32BGRA + config.showsCursor = false + // Match the window's native resolution. + config.width = Int(window.frame.width * 2) // Retina + config.height = Int(window.frame.height * 2) + + let newStream = SCStream(filter: filter, configuration: config, delegate: nil) + do { + try newStream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .global(qos: .userInteractive)) + } catch { + completion(.failure(.captureError("addStreamOutput: \(error)"))) + return + } + + newStream.startCapture { [weak self] error in + if let error = error { + completion(.failure(.captureError("startCapture: \(error)"))) + return + } + self?.stream = newStream + self?.isCapturing = true + completion(.success(Dimensions( + width: Int(window.frame.width * 2), + height: Int(window.frame.height * 2) + ))) + } + } + + // MARK: - SCStreamOutput + + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + guard type == .screen, isCapturing else { return } + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + + guard let jpeg = encoder.encode(pixelBuffer: pixelBuffer) else { return } + onFrame?(jpeg, width, height) + } +} + +// Fallback for macOS < 12.3 — this file won't be reached at runtime +// because build.rs checks SDK version, but the compiler needs the type. +@available(macOS, obsoleted: 12.3, message: "ScreenCaptureKit unavailable") +class FrameCaptureFallback { + init(udid: String) {} + func start(fps: Int, completion: @escaping (Result<(width: Int, height: Int), HelperError>) -> Void) { + completion(.failure(.captureError("ScreenCaptureKit requires macOS 12.3+"))) + } + func stop() {} +} diff --git a/client/src-tauri/native/ios-helper/HidBridge.swift b/client/src-tauri/native/ios-helper/HidBridge.swift new file mode 100644 index 00000000..63b8b249 --- /dev/null +++ b/client/src-tauri/native/ios-helper/HidBridge.swift @@ -0,0 +1,350 @@ +// HidBridge.swift — IndigoHID mach IPC for Simulator input injection. +// +// Communicates with the Simulator's Indigo HID mach service to inject +// touch, swipe, text, and button events. This is the same approach used +// by FBSimulatorControl (Facebook's open-source Simulator control library). +// +// The mach service name follows the pattern: +// com.apple.CoreSimulator.SimDevice..IndigoHID +// +// When the mach port is unavailable (older Xcode, SIP restrictions), +// the caller falls back to AppleScript-based input via cg_input.rs. + +import Foundation + +class HidBridge { + + enum TouchPhase { + case began, moved, ended, cancelled + + static func from(_ string: String) -> TouchPhase { + switch string.lowercased() { + case "began": return .began + case "moved": return .moved + case "ended": return .ended + case "cancelled": return .cancelled + default: return .began + } + } + } + + let udid: String + private var indigoPort: mach_port_t = MACH_PORT_NULL + private var portLookupAttempted = false + private let portLock = NSLock() + + init(udid: String) { + self.udid = udid + } + + // MARK: - Mach port bootstrap + + private func ensurePort() -> mach_port_t { + portLock.lock() + defer { portLock.unlock() } + + if indigoPort != MACH_PORT_NULL { return indigoPort } + if portLookupAttempted { return MACH_PORT_NULL } + + portLookupAttempted = true + let serviceName = "com.apple.CoreSimulator.SimDevice.\(udid).IndigoHID" + + var port: mach_port_t = MACH_PORT_NULL + let kr = bootstrap_look_up(bootstrap_port, serviceName, &port) + + if kr == KERN_SUCCESS { + indigoPort = port + fputs("IndigoHID mach port acquired for \(udid)\n", stderr) + } else { + fputs("IndigoHID mach port lookup failed (kr=\(kr)) for \(serviceName)\n", stderr) + } + + return indigoPort + } + + // MARK: - Touch injection + + func sendTouch(phase: TouchPhase, x: Int, y: Int, completion: @escaping (Result) -> Void) { + let port = ensurePort() + guard port != MACH_PORT_NULL else { + completion(.failure(.hidError("indigo_unavailable"))) + return + } + + // Build IndigoHID touch event message. + // + // The IndigoHID mach message format (reverse-engineered from + // FBSimulatorControl's FBSimulatorIndigoHID): + // + // mach_msg_header_t (standard mach header) + // IndigoHIDEventHeader + // eventType: UInt32 = 2 (touch) + // pathAction: UInt32 (1=began, 2=moved, 3=ended, 4=cancelled) + // x: Float64 + // y: Float64 + // pathIndex: UInt32 = 1 (single finger) + // + // Rather than reproduce the full binary protocol (which varies + // across Xcode versions), we use simctl's sendkey/input as a + // reliable fallback and directly construct messages when the + // mach port is available. + // + // For production use, this should be replaced with a proper + // IndigoHID message builder that handles the specific Xcode + // version's message layout. + + let result = sendIndigoTouchEvent( + port: port, + action: indigoAction(for: phase), + x: Double(x), + y: Double(y) + ) + + if result == KERN_SUCCESS { + completion(.success(())) + } else { + completion(.failure(.hidError("indigo touch failed (kr=\(result))"))) + } + } + + // MARK: - Swipe injection + + func sendSwipe( + fromX: Int, fromY: Int, + toX: Int, toY: Int, + durationMs: Int, + completion: @escaping (Result) -> Void + ) { + let port = ensurePort() + guard port != MACH_PORT_NULL else { + completion(.failure(.hidError("indigo_unavailable"))) + return + } + + // Interpolate touch events across the swipe duration. + let steps = max(6, durationMs / 16) // ~60Hz + let stepDelay = TimeInterval(durationMs) / 1000.0 / TimeInterval(steps) + + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + guard let self = self else { return } + + // Touch down at start. + var kr = self.sendIndigoTouchEvent(port: port, action: 1, x: Double(fromX), y: Double(fromY)) + guard kr == KERN_SUCCESS else { + completion(.failure(.hidError("swipe down failed (kr=\(kr))"))) + return + } + + // Intermediate moves. + for i in 1...steps { + let t = Double(i) / Double(steps) + let cx = Double(fromX) + t * Double(toX - fromX) + let cy = Double(fromY) + t * Double(toY - fromY) + Thread.sleep(forTimeInterval: stepDelay) + kr = self.sendIndigoTouchEvent(port: port, action: 2, x: cx, y: cy) + if kr != KERN_SUCCESS { break } + } + + // Touch up at end. + kr = self.sendIndigoTouchEvent(port: port, action: 3, x: Double(toX), y: Double(toY)) + if kr == KERN_SUCCESS { + completion(.success(())) + } else { + completion(.failure(.hidError("swipe up failed (kr=\(kr))"))) + } + } + } + + // MARK: - Text injection + + func sendText(_ text: String, completion: @escaping (Result) -> Void) { + // Text input via simctl is more reliable than IndigoHID keyboard + // events, which require keycode mapping per locale. + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + proc.arguments = ["simctl", "io", udid, "type", text] + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + + do { + try proc.run() + proc.waitUntilExit() + if proc.terminationStatus == 0 { + completion(.success(())) + } else { + completion(.failure(.hidError("simctl type exit=\(proc.terminationStatus)"))) + } + } catch { + completion(.failure(.hidError("simctl type: \(error)"))) + } + } + + // MARK: - Button injection + + func sendButton(_ button: String, completion: @escaping (Result) -> Void) { + let port = ensurePort() + guard port != MACH_PORT_NULL else { + // Fall back to simctl for buttons when IndigoHID unavailable. + sendButtonViaSimctl(button, completion: completion) + return + } + + // IndigoHID button codes (from FBSimulatorControl): + // home=1, lock/side=2, volumeUp=3, volumeDown=4, siri=5 + let code: UInt32 + switch button.lowercased() { + case "home": code = 1 + case "lock", "side_button", "power": code = 2 + case "volume_up", "vol_up": code = 3 + case "volume_down", "vol_down": code = 4 + case "siri": code = 5 + default: + completion(.failure(.hidError("unknown button: \(button)"))) + return + } + + let kr = sendIndigoButtonEvent(port: port, buttonCode: code) + if kr == KERN_SUCCESS { + completion(.success(())) + } else { + // Fall back to simctl on IndigoHID failure. + sendButtonViaSimctl(button, completion: completion) + } + } + + // MARK: - Simctl fallback for buttons + + private func sendButtonViaSimctl(_ button: String, completion: @escaping (Result) -> Void) { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + + switch button.lowercased() { + case "home": + proc.arguments = ["simctl", "io", udid, "sendkey", "home"] + case "lock", "side_button", "power": + proc.arguments = ["simctl", "io", udid, "sendkey", "lock"] + case "volume_up", "vol_up": + proc.arguments = ["simctl", "io", udid, "sendkey", "volumeUp"] + case "volume_down", "vol_down": + proc.arguments = ["simctl", "io", udid, "sendkey", "volumeDown"] + case "siri": + proc.arguments = ["simctl", "io", udid, "sendkey", "siri"] + default: + completion(.failure(.hidError("unknown button: \(button)"))) + return + } + + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + + do { + try proc.run() + proc.waitUntilExit() + if proc.terminationStatus == 0 { + completion(.success(())) + } else { + completion(.failure(.hidError("simctl sendkey exit=\(proc.terminationStatus)"))) + } + } catch { + completion(.failure(.hidError("simctl sendkey: \(error)"))) + } + } + + // MARK: - IndigoHID mach message primitives + + /// Construct and send an IndigoHID touch event via mach IPC. + /// Returns the mach kernel return code. + /// + /// action: 1=began, 2=moved, 3=ended, 4=cancelled + private func sendIndigoTouchEvent(port: mach_port_t, action: UInt32, x: Double, y: Double) -> kern_return_t { + // IndigoHID touch message layout (simplified, 64-byte body): + // [4B event_type=2][4B action][8B x][8B y][4B path_index=1][4B pressure=1.0] + // [32B padding/reserved] + var body = Data(count: 64) + body.withUnsafeMutableBytes { ptr in + let buf = ptr.bindMemory(to: UInt8.self) + // event_type = 2 (touch) + var eventType: UInt32 = 2 + memcpy(buf.baseAddress!, &eventType, 4) + // action + var act = action + memcpy(buf.baseAddress! + 4, &act, 4) + // x coordinate + var xVal = x + memcpy(buf.baseAddress! + 8, &xVal, 8) + // y coordinate + var yVal = y + memcpy(buf.baseAddress! + 16, &yVal, 8) + // path index = 1 + var pathIndex: UInt32 = 1 + memcpy(buf.baseAddress! + 24, &pathIndex, 4) + // pressure = 1.0 + var pressure: Float = 1.0 + memcpy(buf.baseAddress! + 28, &pressure, 4) + } + + return sendMachMessage(port: port, body: body) + } + + /// Construct and send an IndigoHID button event via mach IPC. + private func sendIndigoButtonEvent(port: mach_port_t, buttonCode: UInt32) -> kern_return_t { + // IndigoHID button message layout (simplified): + // [4B event_type=1][4B button_code] + // [56B padding/reserved] + var body = Data(count: 64) + body.withUnsafeMutableBytes { ptr in + let buf = ptr.bindMemory(to: UInt8.self) + // event_type = 1 (button) + var eventType: UInt32 = 1 + memcpy(buf.baseAddress!, &eventType, 4) + // button code + var code = buttonCode + memcpy(buf.baseAddress! + 4, &code, 4) + } + + return sendMachMessage(port: port, body: body) + } + + /// Send a raw mach message to the IndigoHID port. + private func sendMachMessage(port: mach_port_t, body: Data) -> kern_return_t { + // Total message: mach_msg_header_t (24 bytes) + body + let headerSize = MemoryLayout.size + let totalSize = headerSize + body.count + + var msgBuf = Data(count: totalSize) + return msgBuf.withUnsafeMutableBytes { ptr in + let headerPtr = ptr.baseAddress!.assumingMemoryBound(to: mach_msg_header_t.self) + headerPtr.pointee.msgh_bits = UInt32(MACH_MSG_TYPE_COPY_SEND) + headerPtr.pointee.msgh_size = mach_msg_size_t(totalSize) + headerPtr.pointee.msgh_remote_port = port + headerPtr.pointee.msgh_local_port = MACH_PORT_NULL + headerPtr.pointee.msgh_id = 0 + + // Copy body after header. + body.withUnsafeBytes { bodyPtr in + memcpy(ptr.baseAddress! + headerSize, bodyPtr.baseAddress!, body.count) + } + + return mach_msg( + headerPtr, + MACH_SEND_MSG | MACH_SEND_TIMEOUT, + mach_msg_size_t(totalSize), + 0, + MACH_PORT_NULL, + 500, // 500ms timeout + MACH_PORT_NULL + ) + } + } + + // MARK: - Helpers + + private func indigoAction(for phase: TouchPhase) -> UInt32 { + switch phase { + case .began: return 1 + case .moved: return 2 + case .ended: return 3 + case .cancelled: return 4 + } + } +} diff --git a/client/src-tauri/native/ios-helper/JpegEncoder.swift b/client/src-tauri/native/ios-helper/JpegEncoder.swift new file mode 100644 index 00000000..450f49ad --- /dev/null +++ b/client/src-tauri/native/ios-helper/JpegEncoder.swift @@ -0,0 +1,71 @@ +// JpegEncoder.swift — CVPixelBuffer → JPEG encoding via ImageIO. +// +// Uses CoreGraphics and ImageIO for hardware-accelerated JPEG encoding. +// Quality 0.8 matches the existing JPEG_QUALITY = 80 constant in +// the Rust codec.rs module. + +import Foundation +import CoreGraphics +import CoreVideo +import ImageIO +import UniformTypeIdentifiers + +class JpegEncoder { + + let quality: Float + + init(quality: Float = 0.8) { + self.quality = quality + } + + /// Encode a CVPixelBuffer to JPEG data. + /// Returns nil if encoding fails. + func encode(pixelBuffer: CVPixelBuffer) -> Data? { + CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) } + + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + + guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return nil } + + // Create CGImage from the pixel buffer (BGRA format). + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue + ) else { return nil } + + guard let cgImage = context.makeImage() else { return nil } + return encodeImage(cgImage) + } + + /// Encode a CGImage to JPEG data. + func encodeImage(_ image: CGImage) -> Data? { + let data = NSMutableData() + let typeIdentifier: CFString + if #available(macOS 11.0, *) { + typeIdentifier = UTType.jpeg.identifier as CFString + } else { + typeIdentifier = kUTTypeJPEG + } + + guard let dest = CGImageDestinationCreateWithData(data, typeIdentifier, 1, nil) else { + return nil + } + + let options: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: quality, + ] + CGImageDestinationAddImage(dest, image, options as CFDictionary) + + guard CGImageDestinationFinalize(dest) else { return nil } + return data as Data + } +} diff --git a/client/src-tauri/native/ios-helper/Main.swift b/client/src-tauri/native/ios-helper/Main.swift new file mode 100644 index 00000000..83d454b8 --- /dev/null +++ b/client/src-tauri/native/ios-helper/Main.swift @@ -0,0 +1,161 @@ +// Main.swift — xero-ios-helper entry point. +// +// A macOS daemon that captures the iOS Simulator window via +// ScreenCaptureKit and injects HID events via IndigoHID mach IPC. +// Communicates with the Xero Tauri backend over a Unix domain socket +// using a simple binary framing protocol. +// +// Usage: +// xero-ios-helper --udid --socket-path + +import Foundation + +// MARK: - Argument parsing + +private func parseArgs() -> (udid: String, socketPath: String) { + let args = CommandLine.arguments + var udid: String? + var socketPath: String? + + var i = 1 + while i < args.count { + switch args[i] { + case "--udid" where i + 1 < args.count: + i += 1 + udid = args[i] + case "--socket-path" where i + 1 < args.count: + i += 1 + socketPath = args[i] + default: + break + } + i += 1 + } + + guard let u = udid, let s = socketPath else { + fputs("usage: xero-ios-helper --udid --socket-path \n", stderr) + exit(1) + } + return (u, s) +} + +// MARK: - Signal handling + +private var shutdownRequested = false + +private func installSignalHandler() { + signal(SIGTERM) { _ in + shutdownRequested = true + CFRunLoopStop(CFRunLoopGetMain()) + } + signal(SIGINT) { _ in + shutdownRequested = true + CFRunLoopStop(CFRunLoopGetMain()) + } +} + +// MARK: - Main + +let parsed = parseArgs() +installSignalHandler() + +let connection = Connection(socketPath: parsed.socketPath) +let frameCapture = FrameCapture(udid: parsed.udid) +let hidBridge = HidBridge(udid: parsed.udid) + +// Wire frame capture output to the connection. +frameCapture.onFrame = { [weak connection] jpeg, width, height in + connection?.sendFrame(jpeg: jpeg, width: width, height: height) +} + +frameCapture.onError = { [weak connection] code, message in + connection?.sendEvent(code: code, message: message) +} + +// Wire incoming requests to the appropriate handler. +connection.onRequest = { request, respond in + switch request.method { + case "ping": + respond(.success(["ok": true])) + + case "start_capture": + let fps = (request.params?["fps"] as? Int) ?? 30 + frameCapture.start(fps: fps) { result in + switch result { + case .success(let dims): + respond(.success([ + "ok": true, + "width": dims.width, + "height": dims.height, + ])) + case .failure(let err): + respond(.failure(err)) + } + } + + case "stop_capture": + frameCapture.stop() + respond(.success(["ok": true])) + + case "hid_touch": + guard let params = request.params, + let phaseStr = params["phase"] as? String, + let x = params["x"] as? Int, + let y = params["y"] as? Int else { + respond(.failure(HelperError.invalidParams("hid_touch requires phase, x, y"))) + return + } + let phase = HidBridge.TouchPhase.from(phaseStr) + hidBridge.sendTouch(phase: phase, x: x, y: y) { result in + respond(result.map { ["ok": true] as [String: Any] }) + } + + case "hid_swipe": + guard let params = request.params, + let fromX = params["from_x"] as? Int, + let fromY = params["from_y"] as? Int, + let toX = params["to_x"] as? Int, + let toY = params["to_y"] as? Int else { + respond(.failure(HelperError.invalidParams("hid_swipe requires from_x/y, to_x/y"))) + return + } + let durationMs = (params["duration_ms"] as? Int) ?? 300 + hidBridge.sendSwipe(fromX: fromX, fromY: fromY, toX: toX, toY: toY, durationMs: durationMs) { result in + respond(result.map { ["ok": true] as [String: Any] }) + } + + case "hid_text": + guard let params = request.params, + let text = params["text"] as? String else { + respond(.failure(HelperError.invalidParams("hid_text requires text"))) + return + } + hidBridge.sendText(text) { result in + respond(result.map { ["ok": true] as [String: Any] }) + } + + case "hid_button": + guard let params = request.params, + let button = params["button"] as? String else { + respond(.failure(HelperError.invalidParams("hid_button requires button"))) + return + } + hidBridge.sendButton(button) { result in + respond(result.map { ["ok": true] as [String: Any] }) + } + + default: + respond(.failure(HelperError.unknownMethod(request.method))) + } +} + +// Accept a client connection, then serve until shutdown. +connection.acceptAndServe() + +// RunLoop.main is required for ScreenCaptureKit async callbacks. +RunLoop.main.run() + +// Cleanup on exit. +frameCapture.stop() +connection.close() +exit(0) diff --git a/client/src-tauri/src/commands/emulator/ios/cg_input.rs b/client/src-tauri/src/commands/emulator/ios/cg_input.rs index 4b0a633f..2f6cb38b 100644 --- a/client/src-tauri/src/commands/emulator/ios/cg_input.rs +++ b/client/src-tauri/src/commands/emulator/ios/cg_input.rs @@ -79,6 +79,21 @@ pub fn ax_permission_granted() -> bool { unsafe { AXIsProcessTrusted() } } +/// `true` when Xero has been granted Screen Recording permission (required +/// by ScreenCaptureKit to capture the Simulator window). Checked without +/// triggering any UI — safe to poll. +pub fn screen_recording_permission_granted() -> bool { + unsafe { CGPreflightScreenCaptureAccess() } +} + +/// Trigger macOS's Screen Recording permission prompt. Returns the +/// permission state after the call. On macOS 15+ this opens System +/// Settings → Privacy & Security → Screen Recording. Note: unlike the +/// AX prompt, this may require an app restart to take effect. +pub fn request_screen_recording_permission() -> bool { + unsafe { CGRequestScreenCaptureAccess() } +} + /// Trigger macOS's Accessibility permission prompt. If Xero is already /// registered in System Settings → Privacy & Security → Accessibility, /// this is a no-op and returns the current state. Otherwise a system @@ -517,6 +532,8 @@ fn find_bounds(dict: &CFDictionary, key: &str) -> Option { #[link(name = "CoreGraphics", kind = "framework")] extern "C" { fn CGWindowListCopyWindowInfo(option: u32, relative_to_window: u32) -> CFArrayRef; + fn CGPreflightScreenCaptureAccess() -> bool; + fn CGRequestScreenCaptureAccess() -> bool; } // Accessibility permission lives in HIServices.framework, which is diff --git a/client/src-tauri/src/commands/emulator/ios/helper.rs b/client/src-tauri/src/commands/emulator/ios/helper.rs new file mode 100644 index 00000000..ef084a01 --- /dev/null +++ b/client/src-tauri/src/commands/emulator/ios/helper.rs @@ -0,0 +1,169 @@ +//! Swift helper binary lifecycle management. +//! +//! The `xero-ios-helper` is a standalone macOS daemon that owns all +//! low-level Simulator interaction: ScreenCaptureKit frame capture and +//! IndigoHID input injection. This module handles spawning the helper, +//! health-checking via UDS connectivity, and resolving the binary path. +//! +//! Structurally mirrors `idb_companion.rs` — spawn + ChildGuard + health +//! check loop. + +use std::io::Result; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use tauri::{AppHandle, Manager, Runtime}; + +use crate::commands::emulator::process::ChildGuard; + +/// Launch configuration for the Swift helper binary. +pub struct HelperLaunch { + pub binary: PathBuf, + pub udid: String, + pub socket_path: PathBuf, +} + +impl HelperLaunch { + pub fn new(binary: impl Into, udid: impl Into) -> Result { + let udid = udid.into(); + let socket_path = std::env::temp_dir().join(format!("xero-ios-helper-{udid}.sock")); + // Remove stale socket from a previous crash. + let _ = std::fs::remove_file(&socket_path); + Ok(Self { + binary: binary.into(), + udid, + socket_path, + }) + } +} + +/// Running helper instance. Drop terminates the helper process. +pub struct Helper { + pub socket_path: PathBuf, + pub guard: ChildGuard, +} + +/// Spawn the Swift helper and wait for it to start accepting UDS connections. +pub fn spawn(launch: HelperLaunch, startup_timeout: Duration) -> Result { + let mut cmd = Command::new(&launch.binary); + cmd.args([ + "--udid", + &launch.udid, + "--socket-path", + launch.socket_path.to_str().unwrap_or_default(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let child = cmd.spawn()?; + let mut guard = ChildGuard::new("xero-ios-helper", child); + + let deadline = Instant::now() + startup_timeout; + loop { + // Check if process exited early. + match guard.try_wait() { + Ok(Some(status)) => { + let tail = guard.stderr_tail(); + return Err(std::io::Error::other(format!( + "xero-ios-helper exited before accepting connections (status={status}). \ + stderr: {tail}" + ))); + } + Ok(None) if Instant::now() >= deadline => { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "xero-ios-helper did not start listening on {} within {:?}", + launch.socket_path.display(), + startup_timeout + ), + )); + } + Ok(None) => {} + Err(err) => return Err(err), + } + + // Attempt UDS connection. + if UnixStream::connect(&launch.socket_path).is_ok() { + break; + } + std::thread::sleep(Duration::from_millis(150)); + } + + Ok(Helper { + socket_path: launch.socket_path, + guard, + }) +} + +/// Locate the helper binary. Check (in order): +/// 1. Tauri resource directory (bundled builds) +/// 2. Adjacent to the running executable (dev builds) +/// 3. $PATH +pub fn resolve_helper_binary(app: &AppHandle) -> Option { + const BINARY_NAME: &str = "xero-ios-helper"; + + // 1. Tauri resource directory. + if let Ok(resource_dir) = app.path().resource_dir() { + let candidate: PathBuf = resource_dir.join(BINARY_NAME); + if candidate.is_file() { + return Some(candidate); + } + } + + // 2. Next to the running executable (cargo build puts it in target//). + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join(BINARY_NAME); + if candidate.is_file() { + return Some(candidate); + } + } + } + + // 3. $PATH lookup. + if let Ok(output) = Command::new("which") + .arg(BINARY_NAME) + .output() + { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let p = PathBuf::from(&path); + if p.is_file() { + return Some(p); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn helper_launch_generates_valid_socket_path() { + let launch = + HelperLaunch::new("/usr/bin/true", "AAAA-BBBB-CCCC").expect("launch creation"); + assert!(launch + .socket_path + .to_str() + .unwrap() + .contains("xero-ios-helper-AAAA-BBBB-CCCC.sock")); + } + + #[test] + fn helper_launch_cleans_stale_socket() { + let udid = "test-stale-socket-cleanup"; + let path = std::env::temp_dir().join(format!("xero-ios-helper-{udid}.sock")); + // Create a fake stale file. + std::fs::write(&path, b"stale").ok(); + assert!(path.exists()); + + let _launch = HelperLaunch::new("/usr/bin/true", udid).expect("launch creation"); + assert!(!path.exists(), "stale socket should be removed"); + } +} diff --git a/client/src-tauri/src/commands/emulator/ios/helper_client.rs b/client/src-tauri/src/commands/emulator/ios/helper_client.rs new file mode 100644 index 00000000..161357bb --- /dev/null +++ b/client/src-tauri/src/commands/emulator/ios/helper_client.rs @@ -0,0 +1,376 @@ +//! UDS client for communicating with the Swift helper binary. +//! +//! Binary framing protocol: +//! [1 byte type][4 bytes payload length BE][payload bytes] +//! +//! Types: +//! 0x01 — JSON control message (UTF-8) +//! 0x02 — Frame: payload = [4B width BE][4B height BE][JPEG bytes] + +use std::collections::HashMap; +use std::io::{self, BufWriter, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::commands::emulator::ios::input::{HardwareButton, TouchPhase}; +use crate::commands::CommandError; + +// Message type constants. +const MSG_TYPE_JSON: u8 = 0x01; +const MSG_TYPE_FRAME: u8 = 0x02; + +/// A single captured frame from the Swift helper. +pub struct FrameData { + pub width: u32, + pub height: u32, + pub jpeg: Vec, +} + +/// Client that communicates with xero-ios-helper over a Unix domain socket. +pub struct HelperClient { + writer: Mutex>, + frame_rx: Mutex>>, + responses: Arc>>>, + request_id: AtomicU64, + _reader_thread: JoinHandle<()>, +} + +impl HelperClient { + /// Connect to the helper's UDS and spawn the reader thread. + pub fn connect(socket_path: &Path, timeout: Duration) -> io::Result { + let stream = connect_with_timeout(socket_path, timeout)?; + let read_stream = stream.try_clone()?; + + let (frame_tx, frame_rx) = mpsc::channel::(); + let responses: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let responses_clone = Arc::clone(&responses); + + let reader_thread = thread::Builder::new() + .name("ios-helper-reader".into()) + .spawn(move || { + reader_loop(read_stream, frame_tx, responses_clone); + }) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + Ok(Self { + writer: Mutex::new(BufWriter::new(stream)), + frame_rx: Mutex::new(Some(frame_rx)), + responses, + request_id: AtomicU64::new(1), + _reader_thread: reader_thread, + }) + } + + /// Take ownership of the frame receiver. Called once by the frame pump + /// thread. Returns `None` if already taken. + pub fn take_frame_rx(&self) -> Option> { + self.frame_rx.lock().unwrap().take() + } + + // MARK: - Public API + + /// Start frame capture at the given FPS. Returns (width, height). + pub fn start_capture(&self, fps: u32) -> Result<(u32, u32), CommandError> { + let resp = self.send_request("start_capture", json!({ "fps": fps }))?; + let w = resp["width"].as_u64().unwrap_or(0) as u32; + let h = resp["height"].as_u64().unwrap_or(0) as u32; + Ok((w, h)) + } + + /// Stop frame capture. + pub fn stop_capture(&self) -> Result<(), CommandError> { + self.send_request("stop_capture", json!({}))?; + Ok(()) + } + + /// Send a touch event to the simulator. + pub fn send_touch( + &self, + phase: TouchPhase, + x: i32, + y: i32, + ) -> Result<(), CommandError> { + let phase_str = match phase { + TouchPhase::Began => "began", + TouchPhase::Moved => "moved", + TouchPhase::Ended => "ended", + TouchPhase::Cancelled => "cancelled", + }; + self.send_request( + "hid_touch", + json!({ "phase": phase_str, "x": x, "y": y }), + )?; + Ok(()) + } + + /// Send a swipe gesture. + pub fn send_swipe( + &self, + from_x: i32, + from_y: i32, + to_x: i32, + to_y: i32, + duration_ms: u32, + ) -> Result<(), CommandError> { + self.send_request( + "hid_swipe", + json!({ + "from_x": from_x, "from_y": from_y, + "to_x": to_x, "to_y": to_y, + "duration_ms": duration_ms, + }), + )?; + Ok(()) + } + + /// Inject text into the simulator. + pub fn send_text(&self, text: &str) -> Result<(), CommandError> { + self.send_request("hid_text", json!({ "text": text }))?; + Ok(()) + } + + /// Press a hardware button. + pub fn send_button(&self, button: HardwareButton) -> Result<(), CommandError> { + let name = match button { + HardwareButton::Home => "home", + HardwareButton::Lock => "lock", + HardwareButton::VolumeUp => "volume_up", + HardwareButton::VolumeDown => "volume_down", + HardwareButton::Siri => "siri", + HardwareButton::SideButton => "side_button", + }; + self.send_request("hid_button", json!({ "button": name }))?; + Ok(()) + } + + /// Health check. + pub fn ping(&self) -> Result<(), CommandError> { + self.send_request("ping", json!({}))?; + Ok(()) + } + + // MARK: - Request/response correlation + + fn send_request(&self, method: &str, params: Value) -> Result { + let id = self.request_id.fetch_add(1, Ordering::Relaxed); + + let msg = json!({ + "id": id, + "method": method, + "params": params, + }); + + let payload = serde_json::to_vec(&msg).map_err(|e| { + CommandError::system_fault("ios_helper_serialize", format!("json encode: {e}")) + })?; + + // Create a one-shot channel for the response. + let (tx, rx) = mpsc::channel::(); + { + let mut map = self.responses.lock().unwrap(); + map.insert(id, tx); + } + + // Write the framed message. + { + let mut writer = self.writer.lock().unwrap(); + write_message(&mut *writer, MSG_TYPE_JSON, &payload).map_err(|e| { + CommandError::system_fault("ios_helper_write", format!("socket write: {e}")) + })?; + writer.flush().map_err(|e| { + CommandError::system_fault("ios_helper_flush", format!("socket flush: {e}")) + })?; + } + + // Wait for response with timeout. + let response = rx.recv_timeout(Duration::from_secs(10)).map_err(|_| { + // Clean up the pending entry. + self.responses.lock().unwrap().remove(&id); + CommandError::system_fault( + "ios_helper_timeout", + format!("no response for {method} (id={id}) within 10s"), + ) + })?; + + // Check for error in response. + if let Some(err) = response.get("error") { + let code = err["code"].as_str().unwrap_or("unknown"); + let message = err["message"].as_str().unwrap_or("helper error"); + return Err(CommandError::system_fault( + &format!("ios_helper_{code}"), + message.to_string(), + )); + } + + Ok(response) + } +} + +// MARK: - Reader thread + +fn reader_loop( + mut stream: UnixStream, + frame_tx: mpsc::Sender, + responses: Arc>>>, +) { + loop { + let (msg_type, payload) = match read_message(&mut stream) { + Ok(msg) => msg, + Err(_) => break, // Connection closed or error. + }; + + match msg_type { + MSG_TYPE_JSON => { + if let Ok(value) = serde_json::from_slice::(&payload) { + // Check if this is a response (has `id` field). + if let Some(id) = value["id"].as_u64() { + let tx = { + let mut map = responses.lock().unwrap(); + map.remove(&id) + }; + if let Some(tx) = tx { + // Events (no `id`) can be logged or handled here. + // For now, error events are logged to stderr. + if value.get("event").is_some() { + let code = value["code"].as_str().unwrap_or("unknown"); + let message = value["message"].as_str().unwrap_or(""); + eprintln!("ios-helper event: {code}: {message}"); + } + let _ = tx.send(value); + continue; + } + } + // Events without an id (async events from helper). + if value.get("event").is_some() { + let code = value["code"].as_str().unwrap_or("unknown"); + let message = value["message"].as_str().unwrap_or(""); + eprintln!("ios-helper event: {code}: {message}"); + } + } + } + MSG_TYPE_FRAME => { + if payload.len() >= 8 { + let width = u32::from_be_bytes([ + payload[0], payload[1], payload[2], payload[3], + ]); + let height = u32::from_be_bytes([ + payload[4], payload[5], payload[6], payload[7], + ]); + let jpeg = payload[8..].to_vec(); + let _ = frame_tx.send(FrameData { width, height, jpeg }); + } + } + _ => {} // Unknown type; ignore. + } + } +} + +// MARK: - Framing primitives + +fn write_message(w: &mut impl Write, msg_type: u8, payload: &[u8]) -> io::Result<()> { + // Header: [1 byte type][4 bytes length BE] + w.write_all(&[msg_type])?; + w.write_all(&(payload.len() as u32).to_be_bytes())?; + w.write_all(payload)?; + Ok(()) +} + +fn read_message(r: &mut impl Read) -> io::Result<(u8, Vec)> { + // Read header: 1 byte type + 4 bytes length. + let mut header = [0u8; 5]; + r.read_exact(&mut header)?; + + let msg_type = header[0]; + let length = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize; + + // Sanity limit: 50MB. + if length > 50_000_000 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("message too large: {length}"), + )); + } + + let mut payload = vec![0u8; length]; + r.read_exact(&mut payload)?; + Ok((msg_type, payload)) +} + +/// Connect to a UDS with a retry loop + timeout. +fn connect_with_timeout(path: &Path, timeout: Duration) -> io::Result { + let deadline = std::time::Instant::now() + timeout; + loop { + match UnixStream::connect(path) { + Ok(stream) => return Ok(stream), + Err(e) if std::time::Instant::now() >= deadline => return Err(e), + Err(_) => std::thread::sleep(Duration::from_millis(100)), + } + } +} + +// MARK: - Tests + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn write_read_json_message_roundtrip() { + let payload = b"{\"id\":1,\"ok\":true}"; + let mut buf = Vec::new(); + write_message(&mut buf, MSG_TYPE_JSON, payload).unwrap(); + + let mut cursor = Cursor::new(buf); + let (msg_type, data) = read_message(&mut cursor).unwrap(); + assert_eq!(msg_type, MSG_TYPE_JSON); + assert_eq!(data, payload); + } + + #[test] + fn write_read_frame_message_roundtrip() { + let width: u32 = 1179; + let height: u32 = 2556; + let jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3]; + + // Build frame payload: [4B width BE][4B height BE][JPEG bytes] + let mut payload = Vec::new(); + payload.extend_from_slice(&width.to_be_bytes()); + payload.extend_from_slice(&height.to_be_bytes()); + payload.extend_from_slice(&jpeg); + + let mut buf = Vec::new(); + write_message(&mut buf, MSG_TYPE_FRAME, &payload).unwrap(); + + let mut cursor = Cursor::new(buf); + let (msg_type, data) = read_message(&mut cursor).unwrap(); + assert_eq!(msg_type, MSG_TYPE_FRAME); + assert!(data.len() >= 8); + + let w = u32::from_be_bytes([data[0], data[1], data[2], data[3]]); + let h = u32::from_be_bytes([data[4], data[5], data[6], data[7]]); + let j = &data[8..]; + assert_eq!(w, 1179); + assert_eq!(h, 2556); + assert_eq!(j, &jpeg); + } + + #[test] + fn rejects_oversized_message() { + // Construct a header claiming 100MB payload. + let mut buf = vec![MSG_TYPE_JSON]; + buf.extend_from_slice(&100_000_001u32.to_be_bytes()); + buf.extend_from_slice(&[0u8; 10]); // Not enough data. + + let mut cursor = Cursor::new(buf); + let result = read_message(&mut cursor); + assert!(result.is_err()); + } +} diff --git a/client/src-tauri/src/commands/emulator/ios/mod.rs b/client/src-tauri/src/commands/emulator/ios/mod.rs index 644e9727..656e95f8 100644 --- a/client/src-tauri/src/commands/emulator/ios/mod.rs +++ b/client/src-tauri/src/commands/emulator/ios/mod.rs @@ -6,6 +6,10 @@ pub mod sdk; #[cfg(target_os = "macos")] pub mod cg_input; #[cfg(target_os = "macos")] +pub mod helper; +#[cfg(target_os = "macos")] +pub mod helper_client; +#[cfg(target_os = "macos")] pub mod idb_client; #[cfg(target_os = "macos")] pub mod idb_companion; diff --git a/client/src-tauri/src/commands/emulator/ios/session.rs b/client/src-tauri/src/commands/emulator/ios/session.rs index b1c3d320..d43d7393 100644 --- a/client/src-tauri/src/commands/emulator/ios/session.rs +++ b/client/src-tauri/src/commands/emulator/ios/session.rs @@ -25,6 +25,8 @@ use crate::commands::emulator::{EmulatorInputRequest, InputKind, Orientation}; use crate::commands::CommandError; use super::cg_input; +use super::helper; +use super::helper_client::HelperClient; use super::idb_client::{IdbClient, VideoStreamHandle}; use super::idb_companion::{self, Companion}; use super::input::{self, HidEvent, TouchPhase}; @@ -60,6 +62,11 @@ pub struct IosSession { device_name: String, width: u32, height: u32, + /// Present when the Swift helper (`xero-ios-helper`) was found and + /// spawned. Preferred over idb_companion for both frame capture + /// (ScreenCaptureKit) and HID input (IndigoHID). + helper: Option, + helper_client: Option>, /// Present only when `idb_companion` was found on disk and spawned /// successfully. Preferred for HID input and required for automation /// commands that need idb data (UI dump, log stream). @@ -135,12 +142,18 @@ impl IosSession { } fn send_touch(&self, phase: TouchPhase, nx: f32, ny: f32) -> Result<(), CommandError> { - // Taps route through `cg_input::send_touch`, which on current - // macOS actually dispatches via AppleScript's AX `click at` (see - // the doc on that function — CGEventPostToPid is silently dropped - // on macOS 26, and the bundled idb_companion's CoreSim HID - // bridge is broken for Xcode 26). idb is kept as a last resort - // for the day a working companion ships. + // Prefer Swift helper (IndigoHID) — supports Moved/Ended phases + // unlike the CG/AppleScript path which is click-only. + if let Some(hc) = self.helper_client.as_ref() { + let (x, y) = input::denormalize(nx, ny, self.width.max(1), self.height.max(1)); + if let Ok(()) = hc.send_touch(phase, x, y) { + return Ok(()); + } + // Fall through to CG/idb on helper failure. + } + + // CG path: on macOS 26 dispatches via AppleScript's AX `click at`. + // Only fires on TouchDown (Moved/Ended are no-ops). let cg_result = cg_input::send_touch(&self.device_name, phase, nx, ny); if cg_result.is_ok() || !should_try_idb_after_cg(cg_result.as_ref().unwrap_err()) { return cg_result; @@ -162,6 +175,17 @@ impl IosSession { to_y: f32, duration_ms: u32, ) -> Result<(), CommandError> { + // Prefer Swift helper (IndigoHID) for reliable swipe. + if let Some(hc) = self.helper_client.as_ref() { + let width = self.width.max(1); + let height = self.height.max(1); + let (fx, fy) = input::denormalize(from_x, from_y, width, height); + let (tx, ty) = input::denormalize(to_x, to_y, width, height); + if let Ok(()) = hc.send_swipe(fx, fy, tx, ty, duration_ms) { + return Ok(()); + } + } + let cg_result = cg_input::send_swipe(&self.device_name, from_x, from_y, to_x, to_y, duration_ms); if cg_result.is_ok() || !should_try_idb_after_cg(cg_result.as_ref().unwrap_err()) { @@ -190,6 +214,20 @@ impl IosSession { event: HidEvent, fallback: impl FnOnce(&str) -> Result<(), CommandError>, ) -> Result<(), CommandError> { + // Prefer Swift helper for button/text events. + if let Some(hc) = self.helper_client.as_ref() { + let helper_result = match &event { + HidEvent::Button { button } => hc.send_button(*button), + HidEvent::Text { text } => hc.send_text(text), + HidEvent::Home => hc.send_button(input::HardwareButton::Home), + _ => Err(CommandError::system_fault("unsupported_helper_event", "event type not supported by helper")), + }; + if helper_result.is_ok() { + return helper_result; + } + // Fall through to idb/CG on helper failure. + } + if let Some(client) = self.client.as_ref() { let result = client.send_hid(event); if result.is_ok() || !should_try_cg_fallback(result.as_ref().unwrap_err()) { @@ -275,6 +313,15 @@ impl IosSession { if let Some(handle) = self.fallback_thread.take() { let _ = handle.join(); } + // Shut down Swift helper before idb_companion. + if let Some(ref hc) = self.helper_client { + let _ = hc.stop_capture(); + } + self.helper_client = None; + if let Some(mut h) = self.helper.take() { + let _ = h.guard.shutdown(Duration::from_millis(500)); + let _ = std::fs::remove_file(&h.socket_path); + } if let Some(mut companion) = self.companion.take() { let _ = companion.guard.shutdown(Duration::from_millis(500)); } @@ -354,6 +401,43 @@ pub fn spawn(args: SpawnArgs) -> Result { + match helper::HelperLaunch::new(binary, device_id.clone()) + .and_then(|launch| helper::spawn(launch, COMPANION_TIMEOUT)) + { + Ok(h) => { + match super::helper_client::HelperClient::connect( + &h.socket_path, + Duration::from_secs(5), + ) { + Ok(c) => { + emit_status( + &app, + StatusPhase::Connecting, + &device_id, + Some("Swift helper connected (ScreenCaptureKit + IndigoHID)".into()), + ); + (Some(h), Some(Arc::new(c))) + } + Err(e) => { + eprintln!("xero: helper UDS connect failed: {e}"); + (None, None) + } + } + } + Err(e) => { + eprintln!("xero: helper spawn failed: {e}"); + (None, None) + } + } + } + None => (None, None), + }; + // `idb_companion` is best-effort: when it starts, HID input uses idb's // real simulator surface; when it does not, the session can still render // via screenshots and attempt Core Graphics input. @@ -389,6 +473,7 @@ pub fn spawn(args: SpawnArgs) -> Result(args: SpawnArgs) -> Result(args: SpawnArgs) -> Result( app: &AppHandle, - client: Option<&Arc>, + helper_client: Option<&Arc>, + idb_client: Option<&Arc>, bus: &Arc, device_id: &str, shutdown: Arc, ) -> Result { + // If the Swift helper is connected, pump frames from its channel. + // This replaces both the H.264 decode path and the screenshot fallback + // with ScreenCaptureKit-captured JPEG frames at ~30 FPS. + if let Some(hc) = helper_client { + let (w, h) = hc.start_capture(30).map_err(|e| { + CommandError::system_fault( + "ios_helper_capture_start_failed", + format!("failed to start ScreenCaptureKit capture: {e}"), + ) + })?; + let frame_rx = hc.take_frame_rx().ok_or_else(|| { + CommandError::system_fault( + "ios_helper_frame_rx_taken", + "frame receiver already taken by another pump".to_string(), + ) + })?; + let bus2 = Arc::clone(bus); + let app2 = app.clone(); + let flag = Arc::clone(&shutdown); + let thread = thread::spawn(move || { + while !flag.load(Ordering::Relaxed) { + match frame_rx.recv_timeout(Duration::from_secs(2)) { + Ok(frame) => { + publish_and_emit(&app2, &bus2, frame.width, frame.height, frame.jpeg); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(_) => break, // Channel closed. + } + } + }); + return Ok((w, h, None, Some(thread))); + } + + // Fallback: H.264 stream via idb_companion or screenshot polling. + let client = idb_client; let app_clone = app.clone(); let bus_clone = Arc::clone(bus); let device_for_stream = device_id.to_string(); diff --git a/client/src-tauri/src/commands/emulator/mod.rs b/client/src-tauri/src/commands/emulator/mod.rs index 6408137f..2d819998 100644 --- a/client/src-tauri/src/commands/emulator/mod.rs +++ b/client/src-tauri/src/commands/emulator/mod.rs @@ -282,6 +282,50 @@ pub fn emulator_ios_open_accessibility_settings() -> CommandResult<()> { } } +/// macOS only — trigger the system Screen Recording permission prompt. +/// Required by ScreenCaptureKit for the Swift helper's frame capture. +/// Returns the current permission state after the call. +#[tauri::command] +pub fn emulator_ios_request_screen_recording_permission( + app: AppHandle, +) -> CommandResult { + #[cfg(target_os = "macos")] + { + let granted = ios::cg_input::request_screen_recording_permission(); + let _ = app.emit(EMULATOR_SDK_STATUS_CHANGED_EVENT, ()); + Ok(granted) + } + #[cfg(not(target_os = "macos"))] + { + let _ = app; + Ok(false) + } +} + +/// Open the Privacy & Security → Screen Recording pane in System Settings +/// so the user can enable Xero. macOS-only; on other hosts this is a no-op. +#[tauri::command] +pub fn emulator_ios_open_screen_recording_settings() -> CommandResult<()> { + #[cfg(target_os = "macos")] + { + use std::process::Command; + Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") + .status() + .map_err(|e| { + CommandError::system_fault( + "ios_open_screen_recording_failed", + format!("could not launch System Settings: {e}"), + ) + })?; + Ok(()) + } + #[cfg(not(target_os = "macos"))] + { + Ok(()) + } +} + #[tauri::command] pub fn emulator_list_devices( app: AppHandle, diff --git a/client/src-tauri/src/commands/emulator/sdk.rs b/client/src-tauri/src/commands/emulator/sdk.rs index 4ac55084..804efcfb 100644 --- a/client/src-tauri/src/commands/emulator/sdk.rs +++ b/client/src-tauri/src/commands/emulator/sdk.rs @@ -38,6 +38,12 @@ pub struct IosSdkStatus { /// for `CGEventPostToPid` to deliver taps to Simulator.app. Always `false` /// on non-macOS hosts. pub ax_permission_granted: bool, + /// Xero has been granted Screen Recording permission (macOS) — required + /// by ScreenCaptureKit for the Swift helper's frame capture. Always + /// `false` on non-macOS hosts. + pub screen_recording_permission_granted: bool, + /// The Swift helper binary (`xero-ios-helper`) was found on disk. + pub helper_present: bool, } pub fn probe_sdks(app: &tauri::AppHandle) -> SdkStatus { @@ -69,6 +75,8 @@ fn probe_ios_status(app: &tauri::AppHandle) -> IosSdkStatu idb_companion_present: ios.idb_companion.is_some(), supported: true, ax_permission_granted: super::ios::cg_input::ax_permission_granted(), + screen_recording_permission_granted: super::ios::cg_input::screen_recording_permission_granted(), + helper_present: super::ios::helper::resolve_helper_binary(app).is_some(), } } #[cfg(not(target_os = "macos"))] @@ -81,6 +89,8 @@ fn probe_ios_status(app: &tauri::AppHandle) -> IosSdkStatu idb_companion_present: false, supported: false, ax_permission_granted: false, + screen_recording_permission_granted: false, + helper_present: false, } } } diff --git a/client/src-tauri/src/commands/emulator/shutdown.rs b/client/src-tauri/src/commands/emulator/shutdown.rs index e7d2f160..82082c1a 100644 --- a/client/src-tauri/src/commands/emulator/shutdown.rs +++ b/client/src-tauri/src/commands/emulator/shutdown.rs @@ -111,7 +111,11 @@ fn is_emulator_relevant(name: &str) -> bool { let leaf = lower.rsplit('/').next().unwrap_or(&lower); matches!( leaf, - "qemu-system-aarch64" | "qemu-system-x86_64" | "idb_companion" | "scrcpy-server" + "qemu-system-aarch64" + | "qemu-system-x86_64" + | "idb_companion" + | "scrcpy-server" + | "xero-ios-helper" ) || leaf.starts_with("qemu-system-") || leaf == "emulator64-arm" || leaf == "emulator64-x86" diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 6266bc63..e60e8978 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -311,6 +311,8 @@ pub fn configure_builder_with_state( commands::emulator::emulator_sdk_status, commands::emulator::emulator_ios_request_ax_permission, commands::emulator::emulator_ios_open_accessibility_settings, + commands::emulator::emulator_ios_request_screen_recording_permission, + commands::emulator::emulator_ios_open_screen_recording_settings, commands::emulator::android::provision::emulator_android_provision, commands::emulator::android::provision::emulator_android_provision_status, commands::emulator::emulator_list_devices, diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index 6ad6100a..2ebac073 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -43,7 +43,8 @@ ], "resources": [ "resources/scrcpy-server-v2.7.jar", - "resources/solana-toolchain/**/*" + "resources/solana-toolchain/**/*", + "resources/xero-ios-helper" ] } } From e80dc1708877e4a734877896b0f06cbaa4158198 Mon Sep 17 00:00:00 2001 From: "Md.Sadiq" Date: Sun, 3 May 2026 00:28:01 +0530 Subject: [PATCH 2/5] RN expo integration, it works --- AGENT_CONTEXT_CONTINUITY_AND_MEMORY_PLAN.md | 833 ------------------ DEBUG_AGENT_IMPLEMENTATION_PLAN.md | 56 -- client/components/xero/agent-runtime.test.tsx | 15 - .../xero/agent-runtime/composer-dock.tsx | 13 +- .../xero/emulator-inspector-overlay.tsx | 200 +++++ client/components/xero/emulator-sidebar.tsx | 32 + client/components/xero/phase-view.tsx | 51 +- client/components/xero/shell.tsx | 24 - client/components/xero/workflows-sidebar.tsx | 476 ---------- client/src-tauri/Cargo.lock | 36 + client/src-tauri/Cargo.toml | 12 +- .../native/ios-helper/Connection.swift | 17 +- .../native/ios-helper/HidBridge.swift | 36 +- client/src-tauri/native/ios-helper/Main.swift | 217 ++--- client/src-tauri/resources/xero-ios-helper | Bin 0 -> 161392 bytes .../src/commands/contracts/runtime.rs | 14 +- .../emulator/automation/metro_detect.rs | 56 ++ .../emulator/automation/metro_inspector.rs | 442 ++++++++++ .../src/commands/emulator/automation/mod.rs | 2 + .../src/commands/emulator/ios/session.rs | 106 +-- client/src-tauri/src/commands/emulator/mod.rs | 81 ++ client/src-tauri/src/db/migrations.rs | 2 +- .../src/db/project_store/agent_core.rs | 1 - .../src/db/project_store/project_record.rs | 44 - .../db/project_store/project_record_lance.rs | 1 - client/src-tauri/src/lib.rs | 4 + .../src/runtime/agent_core/persistence.rs | 50 +- .../src/runtime/agent_core/provider_loop.rs | 2 +- .../src/runtime/agent_core/state_machine.rs | 24 - .../runtime/agent_core/tool_descriptors.rs | 57 -- .../src-tauri/src/runtime/agent_core/types.rs | 2 +- .../runtime/autonomous_tool_runtime/mod.rs | 7 +- .../autonomous_tool_runtime/priority_tools.rs | 2 +- client/src/App.tsx | 57 -- client/src/features/emulator/use-inspector.ts | 165 ++++ client/src/features/xero/live-views.test.tsx | 16 +- client/src/lib/xero-model/runtime.test.ts | 18 - client/src/lib/xero-model/runtime.ts | 43 +- 38 files changed, 1257 insertions(+), 1957 deletions(-) delete mode 100644 AGENT_CONTEXT_CONTINUITY_AND_MEMORY_PLAN.md delete mode 100644 DEBUG_AGENT_IMPLEMENTATION_PLAN.md create mode 100644 client/components/xero/emulator-inspector-overlay.tsx delete mode 100644 client/components/xero/workflows-sidebar.tsx create mode 100755 client/src-tauri/resources/xero-ios-helper create mode 100644 client/src-tauri/src/commands/emulator/automation/metro_detect.rs create mode 100644 client/src-tauri/src/commands/emulator/automation/metro_inspector.rs create mode 100644 client/src/features/emulator/use-inspector.ts diff --git a/AGENT_CONTEXT_CONTINUITY_AND_MEMORY_PLAN.md b/AGENT_CONTEXT_CONTINUITY_AND_MEMORY_PLAN.md deleted file mode 100644 index 1994cf66..00000000 --- a/AGENT_CONTEXT_CONTINUITY_AND_MEMORY_PLAN.md +++ /dev/null @@ -1,833 +0,0 @@ -# Agent Context Continuity and Memory Implementation Plan - -## Reader And Outcome - -Reader: an internal Xero engineer or agent implementing the next production slice of the agent runtime. - -Post-read action: implement a production-grade continuity and memory system for Ask, Engineer, and Debug so every agent has the right project context, can safely continue in a new same-type run before context exhaustion, and stores durable knowledge in the app-data backed databases. - -## Golden Rule - -Agents must always be aware of the project and have the proper context for the work they are doing. If context matters, it must be persisted in the database, assembled deterministically into the next provider request or made available through a model-safe retrieval tool, and traceable back to its source. - -## Source Of Truth - -This document is the implementation source of truth for production agent continuity, context assembly, LanceDB retrieval, and durable memory. - -Older Markdown plans for the initial addition of individual agents are historical only. They may help explain how the current implementation got here, but they must not drive this work. If an older plan conflicts with this document, follow this document. - -Corollaries: - -- No provider turn may be sent without a persisted context manifest. -- No important decision, handoff, finding, verification result, project fact, memory candidate, or active task may live only in chat text, local component state, local storage, or a temporary file. -- LanceDB is the retrieval store for durable agent knowledge. SQLite is the transactional store for session, run, policy, lineage, and context assembly state. -- App-data storage is the only valid location for new project state. Repo-local legacy state is not used for new continuity or memory data. -- This is a new application. Do not add compatibility shims unless the user explicitly asks for them. - -## Scope - -This plan covers all three runtime agents: - -- Ask: answer-only, observe-only, project-aware. -- Engineer: implementation agent with planning, editing, verification, and durable engineering records. -- Debug: investigation agent with evidence capture, hypotheses, root cause, fixes, verification, and durable troubleshooting records. - -The implementation must provide: - -- Same-type automatic handoff before context exhaustion. -- Durable project records in LanceDB. -- Reviewed agent memory in LanceDB. -- Model-visible retrieval for relevant LanceDB data. -- Deterministic context packaging for every provider turn. -- Tests that prove these guarantees for Ask, Engineer, and Debug. - -## Current Gaps To Close - -- Context pressure currently results in optional compaction or a user-facing block, not automatic same-type handoff. -- Auto-compaction depends on UI preference state and skips when an active compaction already exists, even if the run is still under pressure. -- Final run handoffs are written to LanceDB project records, but the next agent cannot reliably retrieve those records. -- Approved memory is injected into prompts, but memory extraction and review are manual and the review surface is not part of the main workflow. -- LanceDB schemas reserve embeddings, but embeddings are not populated and there is no semantic retrieval path. -- Debug has the strongest persistence prompt contract. Ask and Engineer need equally explicit durable-context contracts appropriate to their tool boundaries. - -## Non-Negotiable Invariants - -1. Every provider request has a context manifest. - - The manifest records which policies, repository instructions, approved memories, project records, handoff records, compactions, raw-tail messages, tool descriptors, active tasks, and file-change summaries were included. - - The manifest records what was excluded and why. - - The manifest is stored before the provider call starts. - -2. Same-type handoff is automatic. - - If Ask is under context pressure, the target run is Ask. - - If Engineer is under context pressure, the target run is Engineer. - - If Debug is under context pressure, the target run is Debug. - - Provider, model, thinking effort, approval mode, and plan-mode settings carry forward unless the active provider is unavailable. - -3. Database write failure blocks continuation. - - If Xero cannot persist the handoff, context manifest, run state, or required project record, it must not continue with an untracked provider call. - - The old run remains resumable or explicitly blocked with a diagnostic. - -4. Runtime-owned persistence is the default. - - Ask does not need mutating tools to record useful information. - - The runtime captures Ask outputs, summaries, and memory candidates after the turn. - - Engineer and Debug can additionally propose records, but runtime validation, redaction, and policy gates own the final write. - -5. Retrieval context is lower priority than policy. - - Retrieved records and memories can inform the agent but can never override system policy, tool policy, repository instructions, approvals, or user intent. - - Prompt-injection text inside memory or project records is treated as data. - -6. Secrets never become memory. - - Redaction runs before project-record insertion, memory-candidate insertion, prompt injection, retrieval display, and handoff generation. - - Secret-like records are blocked or redacted before they become model-visible. - -7. Ask stays observe-only. - - Ask may receive context and use safe retrieval. - - Ask may not mutate files, run commands, spawn subagents, control browsers, install skills, or write records directly. - - Ask persistence is runtime-owned after the model turn. - -8. Engineer and Debug preserve evidence. - - File changes, tool calls, command results, verification attempts, plans, unresolved questions, and blockers are stored in DB-backed run artifacts or project records. - -9. Handoff is idempotent. - - Retrying the same source run and source context hash must not create duplicate target handoffs. - - If a handoff target was already created, Xero resumes or reconnects it instead of creating another. - -10. The context package is reproducible. - - Given the same session, run state, retrieval query, and policy version, Xero can reconstruct the provider-visible context package. - -## Target Architecture - -The system is split into seven cooperating services. - -### 1. Context Policy Engine - -Responsibilities: - -- Estimate context pressure before every provider turn. -- Decide whether to continue, compact, recompact, hand off, or block. -- Treat compaction as a reduction step, not the final reliability mechanism. -- Trigger handoff when context remains high after compaction, when compaction is unavailable, or when the next turn would exceed the configured threshold. - -Required decisions: - -- `continue_now`: context is healthy. -- `compact_now`: context can be reduced safely before continuing. -- `recompact_now`: an old compaction exists but no longer protects the next turn. -- `handoff_now`: create a new same-type run and seed it with durable state. -- `blocked`: required context cannot be stored, retrieved, or safely redacted. - -Default thresholds: - -- Compact at 75 percent estimated context. -- Handoff at 90 percent estimated context. -- Hard block only when handoff cannot be created or required context cannot be persisted. - -Thresholds must be durable project or session settings, not local-storage-only behavior. - -### 2. Context Manifest Store - -Responsibilities: - -- Persist a manifest for every provider request. -- Record included and excluded context contributors. -- Record token estimates, pressure, policy decisions, retrieval query IDs, retrieval result IDs, compaction IDs, handoff IDs, and redaction state. -- Provide diagnostics in the Context panel. - -The manifest is transactional state and belongs in SQLite. - -### 3. Project Knowledge Store - -Responsibilities: - -- Store durable retrieval records in LanceDB. -- Support record kinds for handoffs, project facts, decisions, constraints, plans, findings, verification, questions, artifacts, context notes, and diagnostics. -- Populate embeddings for every retrievable record. -- Support keyword and metadata filtering when semantic search is unavailable. -- Track source item IDs, run IDs, session IDs, agent IDs, related paths, schema name, schema version, importance, confidence, tags, visibility, redaction state, and timestamps. - -LanceDB records are append-friendly, but writes must be idempotent using source IDs and content hashes. - -### 4. Reviewed Memory Store - -Responsibilities: - -- Store memory candidates and approved memories in LanceDB. -- Distinguish project-scoped and session-scoped memory. -- Keep approval, enablement, confidence, source, and diagnostic metadata. -- Inject only approved and enabled memories. -- Automatically create candidates after completion, pause, failure, and handoff. -- Require review before candidates become approved memory unless the user later opts into an explicit auto-approval policy. - -### 5. Retrieval Service - -Responsibilities: - -- Search project records and reviewed memories. -- Support hybrid retrieval: vector similarity, keyword search, tags, kinds, related paths, runtime agent, session, recency, importance, and confidence. -- Return compact, cited snippets with record IDs and source metadata. -- Log retrieval queries and selected results in SQLite for replay and diagnostics. -- Expose safe read-only retrieval to all three agents. - -Retrieval must be available in two ways: - -- Automatic prompt injection of top relevant context. -- A read-only context tool the model can call when it needs more detail. - -### 6. Handoff Orchestrator - -Responsibilities: - -- Create a durable handoff bundle from current DB state. -- Persist the bundle to LanceDB as a project record. -- Persist handoff lineage in SQLite. -- Create or reconnect a new same-type target run. -- Seed the target run with the handoff bundle, approved memory, relevant records, active tasks, recent raw tail, and current user intent. -- Mark the source run as handed off when the target run is durably created. -- Recover cleanly after process crash. - -### 7. Agent Contract Compiler - -Responsibilities: - -- Compile agent-specific prompt contracts for Ask, Engineer, and Debug. -- Include the same persistence and retrieval guarantees across all agents, adjusted for each tool boundary. -- Make it explicit that durable context comes from Xero and is lower priority than system and tool policy. -- Enforce Ask observe-only rules while still giving Ask access to read-only project context. - -## Data Model Plan - -### SQLite Transactional State - -Store these as durable transactional records: - -- Runtime sessions and runtime runs. -- Agent runs, messages, events, tool calls, tool results, file changes, checkpoints, action requests, approvals, todos, and usage. -- Context manifests for every provider request. -- Context policy decisions. -- Compaction metadata and source hashes. -- Handoff lineage and target/source run links. -- Handoff creation attempts and failure diagnostics. -- Retrieval query logs and selected retrieval results. -- Memory extraction jobs and diagnostics. -- Durable project or session settings for context thresholds and auto-handoff behavior. -- Schema and policy versions used to assemble each provider request. - -### LanceDB Retrieval State - -Store these as retrievable records: - -- Agent handoffs. -- Project facts. -- User decisions that affect the project. -- Constraints from users, repository instructions, or runtime policy. -- Plans and active task summaries. -- Findings and evidence. -- Verification commands and results. -- Open questions and blockers. -- Artifact summaries and references. -- Context notes. -- Diagnostics. -- Reviewed memories. -- Memory candidates. - -Every retrievable row must include: - -- Stable ID. -- Project ID. -- Optional session ID. -- Optional run ID. -- Runtime agent ID. -- Record kind. -- Title. -- Summary. -- Full text. -- Structured content JSON. -- Source item IDs. -- Related paths or symbols when known. -- Tags. -- Importance. -- Confidence. -- Visibility. -- Redaction state. -- Embedding vector. -- Embedding model and embedding version. -- Created and updated timestamps. - -### Embeddings - -Implement embeddings as a real service, not a placeholder column. - -Requirements: - -- Use a provider-neutral embedding interface. -- Store embedding model, dimension, and version with each embedded row. -- Refuse semantic retrieval if the configured embedding dimension does not match the table. -- Provide deterministic keyword fallback when embeddings are unavailable. -- Backfill missing embeddings through a durable job queue. -- Never inject unembedded records solely because semantic search failed; use recency, importance, kind, and keyword filters as fallback. - -## Handoff Design - -### Trigger Points - -Evaluate context policy at these points: - -- Before initial provider call. -- Before every user continuation. -- Before every provider call after tool results are appended. -- After large tool results are stored. -- After compaction is created. -- After memory or project-record injection changes the prompt. -- Before resuming from an approval wait. - -### Handoff Bundle - -A handoff bundle must be structured enough for a new agent to continue without asking the user to restate context. - -Required fields: - -- Source project, session, and run IDs. -- Target runtime agent ID. -- Provider and model settings. -- User goal and current task. -- Current status. -- Completed work. -- Pending work. -- Active todo items. -- Important decisions. -- Constraints. -- Relevant project facts. -- Recent file changes. -- Tool and command evidence. -- Verification status. -- Known risks. -- Open questions. -- Relevant approved memories. -- Relevant project records. -- Recent raw-tail message references. -- Source context hash. -- Redaction state. - -Debug handoffs additionally require: - -- Symptom. -- Reproduction path. -- Evidence ledger. -- Hypotheses tested. -- Root cause. -- Fix rationale. -- Verification evidence. -- Reusable troubleshooting facts. - -Engineer handoffs additionally require: - -- Implementation plan state. -- Files changed or intended. -- Build and test status. -- Remaining edits. -- Review risks. - -Ask handoffs additionally require: - -- Question being answered. -- Project context used. -- Uncertainties. -- Follow-up information needed. - -### Handoff Algorithm - -1. Flush run state. - - Persist pending messages, tool results, file-change summaries, todo state, approvals, usage, and diagnostics. - -2. Recompute context pressure. - - If pressure is below threshold, continue normally. - - If pressure is high, try compaction or recompaction. - - If pressure remains high or compaction is unavailable, hand off. - -3. Build a source manifest. - - Include active compaction, raw tail, important records, memories, tool summaries, file changes, and open tasks. - -4. Generate the handoff bundle. - - Prefer deterministic extraction from DB state. - - Use provider summarization only to improve wording and condensation. - - Validate that required fields are present. - -5. Redact and validate. - - Block secret-bearing or instruction-overriding content. - - Preserve source IDs for redacted entries. - -6. Persist handoff. - - Write SQLite handoff lineage first as `pending`. - - Write LanceDB project record with idempotency key. - - Update SQLite lineage to `recorded`. - -7. Create the target run. - - Same runtime agent type. - - Same session unless a new session boundary is required by the product design. - - Same provider controls unless unavailable. - - Seed the target run with a system-owned handoff message and the pending user prompt when applicable. - -8. Mark source and target. - - Source run becomes `handed_off`. - - Target run becomes `running` or `ready`. - - Runtime UI points the composer to the target run. - -9. Continue. - - The next provider request uses the target run context manifest. - -### Failure Handling - -- If handoff generation fails, retry with deterministic DB-only bundle. -- If LanceDB insert fails, do not create the target run. -- If target run creation fails after handoff record insertion, keep lineage as `recorded` and retry target creation by idempotency key. -- If the app crashes mid-handoff, startup recovery resumes pending handoffs or marks them failed with diagnostics. -- If redaction blocks required context, stop and ask the user how to proceed. -- If retrieval fails, continue only with required context that is already in the manifest; otherwise block. - -## Context Package Design - -Every provider request is assembled from a context package. - -Required contributors: - -- Runtime system policy. -- Active tool policy. -- Agent-specific contract. -- Repository instructions. -- Current user prompt or queued prompt. -- Current run and session state. -- Context pressure and policy decision. -- Active compaction summary when present. -- Recent raw conversation tail. -- Approved memory. -- Relevant project records. -- Active handoff bundle when present. -- Active todo or plan state. -- File-change summary. -- Required tool descriptors. - -Optional contributors: - -- Process state digest. -- Code map. -- Artifact summaries. -- Retrieval snippets requested by the model. -- Lower-priority historical records. - -Priority rules: - -1. System and runtime policy. -2. User request and operator approvals. -3. Repository instructions. -4. Active task state and handoff bundle. -5. Current raw tail. -6. Approved memory. -7. Relevant project records. -8. Tool output summaries and artifacts. -9. Deferred historical context. - -If required contributors cannot fit, Xero must hand off or block. It must not silently drop required context and continue. - -## Agent-Specific Requirements - -### Ask - -Ask must: - -- Receive the same project context package as other agents. -- Have read-only access to approved memory and relevant project records. -- Be able to call a safe retrieval tool. -- Never mutate files, app state, processes, browser state, external services, skills, subagents, or DB records directly. -- Have its final answer captured by the runtime as a project record when useful. -- Produce memory candidates through runtime-owned extraction after completion. - -Ask prompt contract must include: - -- Answer directly. -- Cite project facts or uncertainty when relevant. -- Name important files, symbols, decisions, or constraints when useful. -- Include a concise handoff-quality final answer when the conversation may continue. -- Do not include secrets. - -### Engineer - -Engineer must: - -- Receive the full project context package. -- Use planning and verification gates for non-trivial work. -- Store meaningful plans, decisions, file-change summaries, verification results, blockers, and final handoffs. -- Use retrieval before acting when the task references prior work, project decisions, known constraints, or previous failures. -- Create memory candidates after completion or handoff. - -Engineer prompt contract must include: - -- Inspect before editing. -- Keep changes scoped. -- Preserve dirty worktree safety. -- Record decisions and verification evidence. -- Summarize changed files, tests, blockers, and follow-ups in a durable handoff-friendly final answer. - -### Debug - -Debug must: - -- Receive the full project context package. -- Retrieve prior debugging records and troubleshooting memories before investigating. -- Maintain evidence, hypotheses, experiments, root cause, fix rationale, and verification. -- Store debugging findings, root causes, fixes, verification records, and troubleshooting facts. -- Create high-importance project records for durable debugging knowledge. - -Debug prompt contract must include: - -- Prefer evidence over confidence. -- Reproduce or tightly simulate the issue. -- Test falsifiable hypotheses. -- Preserve reusable troubleshooting knowledge. -- Include symptom, root cause, fix, verification, remaining risks, and saved debugging knowledge in the final answer. - -## Model-Visible Tools - -Add a read-only project context tool available to Ask, Engineer, and Debug. - -Actions: - -- Search project records. -- Search approved memory. -- Get a project record by ID. -- Get a memory by ID. -- List recent handoffs. -- List active decisions and constraints. -- List open questions and blockers. -- Explain current context package. - -Tool constraints: - -- Ask can only use read-only actions. -- Engineer and Debug can use read-only actions and may propose new records through a separate candidate action if policy allows. -- Any write-like action creates a candidate or runtime-owned request, not an immediately trusted memory. -- Tool results include source IDs and redaction state. -- Tool results are recorded in the run log. - -Add a runtime-owned record capture path. - -Capture sources: - -- Final assistant messages. -- Handoff bundles. -- Plans. -- Todo state transitions. -- Verification results. -- Debug findings. -- Tool-result summaries. -- User decisions and constraints. -- Memory extraction candidates. - -Capture must be automatic for all agents. - -## UI Requirements - -Use the existing ShadCN-based UI patterns. - -Required surfaces: - -- Context panel showing current pressure, manifest, included contributors, excluded contributors, and policy decisions. -- Handoff event display showing source run, target run, agent type, status, and diagnostics. -- Memory review surface mounted in the normal runtime workflow. -- Project records surface or context inspector for recent handoffs, decisions, constraints, findings, and verification records. -- Retrieval diagnostics for what was injected into the prompt. -- User controls for durable context thresholds and auto-handoff settings. - -UI rules: - -- Do not add temporary debug or test UI. -- Do not require a browser workflow for verification because this is a Tauri app. -- Do not make persistence depend on whether a UI panel is open. -- UI preferences may live in local state, but core continuity policy must live in DB-backed settings. - -## Implementation Phases - -### Phase 1: Contracts And Durable Settings - -Deliverables: - -- Define context policy actions, including handoff. -- Define handoff lineage records. -- Define context manifest records. -- Define durable context policy settings. -- Define retrieval query/result logs. -- Update agent prompt contracts for Ask, Engineer, and Debug. -- Add schema tests for the new contracts. - -Acceptance criteria: - -- All three agents have explicit persistence and retrieval contracts. -- Auto-handoff thresholds are DB-backed. -- A context manifest can be persisted without a provider call. -- Tests prove same-type handoff decisions preserve the runtime agent ID. - -### Phase 2: LanceDB Retrieval Foundation - -Deliverables: - -- Populate embeddings for project records and reviewed memories. -- Add embedding model and version metadata. -- Add hybrid search over project records and memories. -- Add keyword fallback. -- Add idempotent insert and backfill jobs. -- Add retrieval query/result logging. - -Acceptance criteria: - -- Inserted project records and memories have non-null embeddings when embedding service is configured. -- Search returns filtered, cited results by kind, tag, path, agent, session, recency, importance, and text. -- Retrieval failures are diagnosable and do not silently inject empty context. -- Tests cover embedding mismatch, fallback retrieval, redaction, and deduplication. - -### Phase 3: Context Package Assembler - -Deliverables: - -- Build a deterministic context package for every provider request. -- Include approved memory and relevant project records for all agents. -- Persist context manifests before provider calls. -- Add contributor priority and exclusion reasons. -- Add prompt-injection defenses for retrieved content. - -Acceptance criteria: - -- Every provider request has a stored manifest. -- Ask, Engineer, and Debug all receive approved memory and relevant project records. -- Required context is never silently dropped. -- Tests prove manifests are reproducible from DB state. - -### Phase 4: Handoff Orchestrator - -Deliverables: - -- Add handoff trigger evaluation after compaction. -- Generate structured handoff bundles. -- Persist handoff bundles to LanceDB. -- Persist handoff lineage to SQLite. -- Create or reconnect same-type target runs. -- Seed target runs with handoff context. -- Add crash recovery for pending handoffs. - -Acceptance criteria: - -- A synthetic long Ask run hands off to Ask. -- A synthetic long Engineer run hands off to Engineer. -- A synthetic long Debug run hands off to Debug. -- Target runs can continue without the user restating the task. -- Duplicate retries do not create duplicate handoffs or target runs. -- If handoff persistence fails, no provider call is made. - -### Phase 5: Automatic Record Capture And Memory Candidates - -Deliverables: - -- Capture final answers, plans, decisions, verification, findings, diagnostics, and handoffs as project records. -- Run memory extraction after completion, pause, failure, and handoff. -- Store memory candidates disabled until reviewed. -- Mount the memory review workflow. -- Add user-visible diagnostics for rejected candidates. - -Acceptance criteria: - -- Useful run information is stored even when the model did not explicitly call a record tool. -- Approved memories are injected into future Ask, Engineer, and Debug runs. -- Candidate memories never become approved without review. -- Secret-like and instruction-overriding candidates are blocked. - -### Phase 6: Model-Visible Context Tooling - -Deliverables: - -- Add read-only project context retrieval tool. -- Make it available to all agents. -- Keep Ask observe-only. -- Add candidate record proposal actions for agents that are allowed to request writes. -- Record every retrieval tool call and result. - -Acceptance criteria: - -- Ask can search and read records but cannot write. -- Engineer and Debug can retrieve context before acting. -- Tool results are source-cited, redacted, and logged. -- Tests prove permission boundaries. - -### Phase 7: UI And Operator Experience - -Deliverables: - -- Context panel shows manifests, budget pressure, handoff policy, retrieval injections, and diagnostics. -- Handoff status appears in the runtime stream. -- Memory review is reachable from normal workflow. -- Project records can be inspected without developer-only UI. -- Durable policy settings are editable if product design allows. - -Acceptance criteria: - -- Users can understand why handoff happened. -- Users can review memory candidates. -- Users can inspect what context was used. -- No temporary or test-only UI is introduced. - -### Phase 8: Hardening And Release Gate - -Deliverables: - -- Scoped Rust tests. -- Scoped TypeScript tests. -- Tauri command contract tests. -- Crash recovery tests. -- Redaction and prompt-injection tests. -- Context pressure and handoff stress tests. -- Documentation for the runtime behavior. - -Acceptance criteria: - -- All scoped tests pass. -- No new state is written to legacy repo-local locations. -- Long-running sessions automatically continue through same-type handoff. -- LanceDB retrieval is available to all three agents. -- DB failures block unsafe continuation. -- The app can restart during a pending handoff and recover deterministically. - -## Test Matrix - -### Rust Unit Tests - -- Context policy chooses compact below handoff threshold. -- Context policy chooses handoff above handoff threshold. -- Active compaction does not prevent handoff when context remains high. -- Unknown provider budget falls back to configurable conservative thresholds or blocks with diagnostics. -- Same-type handoff preserves Ask, Engineer, and Debug. -- Handoff bundle validation rejects missing required fields. -- Handoff insert is idempotent by source hash. -- Context manifest persists before provider call. -- Prompt compiler includes approved memory for all agents. -- Prompt compiler includes relevant project records for all agents. -- Ask tool permissions remain read-only. -- Engineer and Debug keep engineering tool access. -- Redaction blocks secret-bearing records. -- Retrieval fallback works without embeddings. -- Embedding mismatch is detected. -- No new state writes to legacy repo-local storage. - -### TypeScript Unit Tests - -- Agent descriptors expose persistence and retrieval policies for all three agents. -- Context panel renders budget pressure and policy decisions. -- Handoff events render source and target run IDs. -- Memory review workflow is mounted and can approve, reject, enable, disable, and delete records. -- Ask cannot display write-only controls. -- Durable settings are not represented as local-storage-only core behavior. - -### Integration Tests - -- Long Ask session hands off to Ask and answers using prior context. -- Long Engineer session hands off to Engineer and continues pending implementation work. -- Long Debug session hands off to Debug and preserves evidence, hypotheses, and root cause. -- Run completion creates project records and memory candidates. -- Approved memory from one run is injected into a later run. -- Project record retrieval finds a prior decision during a later task. -- Crash during pending handoff recovers without duplicate target runs. -- LanceDB unavailable blocks provider continuation with diagnostics. -- Redaction prevents secrets from becoming model-visible. - -## Operational Hardening - -### Idempotency - -Use stable idempotency keys for: - -- Context manifests. -- Handoff bundles. -- Handoff lineage. -- Project records. -- Memory candidates. -- Retrieval logs. - -Recommended handoff key: - -- Project ID. -- Source session ID. -- Source run ID. -- Source context hash. -- Target runtime agent ID. -- Pending prompt hash when present. - -### Concurrency - -Rules: - -- Only one handoff can be pending for a source run. -- A target run is created under a DB-backed lock or uniqueness constraint. -- Retrieval and memory extraction jobs may run concurrently, but record insertion must deduplicate. -- Runtime state changes must be monotonic and recoverable. - -### Crash Recovery - -Startup recovery must: - -- Find pending handoff attempts. -- Resume handoff record creation or target run creation. -- Mark unrecoverable attempts failed with diagnostics. -- Never orphan a completed handoff record without discoverable lineage. -- Never continue a provider call from an unmanifested context package. - -### Security - -Rules: - -- Treat retrieved records as untrusted lower-priority data. -- Redact secrets before storage and before prompt injection. -- Block memory candidates that try to override policy. -- Keep source references for redacted records. -- Log why a record was blocked or redacted. -- Do not expose raw LanceDB mutation to models. - -### Performance - -Targets: - -- Context policy evaluation should be fast enough to run before every provider turn. -- Prompt assembly should use bounded retrieval limits. -- LanceDB search should use filters before wide scans when possible. -- Embedding backfills should be queued and resumable. -- Large tool results should be summarized into records, with raw payloads stored only where appropriate. - -## Definition Of Done - -The implementation is complete when all of the following are true: - -- Ask, Engineer, and Debug all receive a persisted context package before every provider call. -- Context manifests are stored for every provider request. -- Relevant approved memory and project records are available to every agent. -- Long sessions hand off automatically to a new same-type run before context exhaustion. -- Handoff bundles are persisted to LanceDB and linked in SQLite. -- Final answers, decisions, findings, plans, verification results, diagnostics, and handoffs are stored in DB-backed records. -- Memory candidates are created automatically and require review before injection. -- LanceDB embeddings are populated or a tested fallback retrieval path is used. -- Ask remains observe-only. -- Engineer and Debug retain engineering capabilities and durable evidence capture. -- DB write failure prevents unsafe continuation. -- Crash recovery handles pending handoffs. -- Scoped Rust and TypeScript tests prove the behavior. -- No new project state is written to legacy repo-local state. - -## First Implementation Slice - -Start with the smallest vertical slice that proves the architecture: - -1. Add context manifest records. -2. Add handoff policy action. -3. Add deterministic handoff bundle generation. -4. Persist the handoff bundle as a LanceDB project record. -5. Persist handoff lineage in SQLite. -6. Create a same-type target run from a synthetic over-budget source run. -7. Seed the target run with the handoff bundle. -8. Compile a context package that includes the handoff, approved memory, and relevant project records. -9. Prove the slice for Ask, Engineer, and Debug with fake-provider tests. - -Do not start with UI polish. The first slice must prove that a provider can safely continue from durable DB state after a same-type handoff. diff --git a/DEBUG_AGENT_IMPLEMENTATION_PLAN.md b/DEBUG_AGENT_IMPLEMENTATION_PLAN.md deleted file mode 100644 index d0f2472c..00000000 --- a/DEBUG_AGENT_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,56 +0,0 @@ -# Debug Agent Implementation Plan - -## Goal - -Add a third agent composer option, `Debug`, alongside `Ask` and `Engineer`. Debug should behave like a rich investigation-focused engineering agent: it can inspect, edit, and verify, but its workflow should emphasize evidence capture, hypothesis testing, root-cause analysis, fix validation, and durable session memory. - -## Constraints - -- Use the existing ShadCN-based composer controls and runtime patterns. -- Do not add temporary debug or test-only UI. -- This is a Tauri app, so verification should use unit and Rust tests rather than browser checks. -- Run scoped tests and formatting only. -- Keep project state in the existing OS app-data backed stores; do not use `.xero/`. -- Preserve existing dirty worktree changes that are unrelated to this implementation. - -## Implementation Steps - -1. Add `debug` to the shared runtime-agent contract. - - Extend TypeScript schemas and descriptors in `client/src/lib/xero-model/runtime.ts`. - - Extend Rust `RuntimeAgentIdDto` parsing, labels, approval-mode rules, and plan/verification gate support. - - Update SQLite runtime-agent `CHECK` constraints for new installs. - -2. Add Debug to the composer UI. - - Reuse existing ShadCN/Radix controls. - - Add a distinct icon and labels for the Debug option. - - Allow the same approval selector modes as Engineer. - - Keep Ask observe-only behavior unchanged. - -3. Add Debug runtime prompt and tool policy. - - Give Debug full engineering tool access. - - Require a structured debugging workflow: intake, reproduction, evidence ledger, hypotheses, experiments, root cause, fix, regression verification, and session summary. - - Instruct Debug to preserve useful debugging facts for future retrieval while treating memory as lower-priority context. - -4. Ensure durable LanceDB project records are useful for Debug sessions. - - Keep the existing run-handoff persistence path that writes project records through the Lance-backed project record store. - - Add Debug-specific structured content fields/tags where possible, so later retrieval can distinguish debugging sessions, fixes, root causes, verification evidence, and affected paths. - - Avoid adding backwards compatibility shims unless needed for current tests. - -5. Update targeted tests. - - TypeScript runtime schema/descriptor tests for `debug`. - - React composer tests for Debug selection and approval behavior. - - Rust contract/prompt/tool filtering tests for Debug. - - Scoped test commands only. - -## Expected User-Facing Behavior - -- The composer agent selector shows `Ask`, `Engineer`, and `Debug`. -- Selecting `Debug` keeps the rich model/thinking/approval controls available. -- Debug runs use a structured system prompt that pushes the agent to document evidence, hypotheses, fixes, and verification. -- Debug final summaries and run handoffs are saved as LanceDB-backed project records with `debug` runtime metadata and retrieval tags. - -## Verification Plan - -- Run focused TypeScript tests around runtime models and agent runtime composer. -- Run focused Rust tests around runtime contracts, tool descriptors, state-machine gates, persistence, and project-record parsing. -- Run scoped formatting for touched TypeScript/Rust files if the repo tooling supports it cleanly. diff --git a/client/components/xero/agent-runtime.test.tsx b/client/components/xero/agent-runtime.test.tsx index 5397ee7a..26ea2b98 100644 --- a/client/components/xero/agent-runtime.test.tsx +++ b/client/components/xero/agent-runtime.test.tsx @@ -1169,21 +1169,6 @@ describe('AgentRuntime current UI', () => { expect(onOpenDiagnostics).toHaveBeenCalledTimes(1) }) - it('renders Debug as an approval-capable composer agent', () => { - render( - , - ) - - expect(screen.getByRole('combobox', { name: 'Agent selector' })).toHaveTextContent('Debug') - expect(screen.getByRole('combobox', { name: 'Approval mode selector' })).toHaveTextContent('Auto edit') - }) - it('keeps model selectors available while a prompt is pending on an active run', async () => { const onUpdateRuntimeRunControls = vi.fn(async () => makeRuntimeRun()) diff --git a/client/components/xero/agent-runtime/composer-dock.tsx b/client/components/xero/agent-runtime/composer-dock.tsx index d49da211..f1b258b0 100644 --- a/client/components/xero/agent-runtime/composer-dock.tsx +++ b/client/components/xero/agent-runtime/composer-dock.tsx @@ -1,4 +1,4 @@ -import { Activity, ArrowUp, Brain, Bug, CheckIcon, ChevronDownIcon, Cpu, LoaderCircle, MessageCircle, Mic, ShieldCheck, Sparkles, Wrench } from 'lucide-react' +import { Activity, ArrowUp, Brain, CheckIcon, ChevronDownIcon, Cpu, LoaderCircle, MessageCircle, Mic, ShieldCheck, Sparkles, Wrench } from 'lucide-react' import * as SelectPrimitive from '@radix-ui/react-select' import { forwardRef, Fragment, useMemo, useState, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode, type RefObject } from 'react' @@ -9,7 +9,6 @@ import type { } from '@/src/features/xero/use-xero-desktop-state/types' import { RUNTIME_AGENT_DESCRIPTORS, - runtimeAgentIdSchema, type ProviderModelThinkingEffortDto, type RuntimeAgentIdDto, type RuntimeRunApprovalModeDto, @@ -156,7 +155,7 @@ export function ComposerDock({ }: ComposerDockProps) { const hasComposerModelOptions = composerModelGroups.length > 0 const hasThinkingOptions = composerThinkingOptions.length > 0 - const showApprovalSelector = composerRuntimeAgentId !== 'ask' + const showApprovalSelector = composerRuntimeAgentId === 'engineer' const isAgentSelectorDisabled = runtimeAgentSwitchDisabled || controlsDisabled const isUpdatingControls = runtimeRunActionStatus === 'running' && pendingRuntimeRunAction === 'update_controls' const isStartingRun = @@ -165,8 +164,7 @@ export function ComposerDock({ composerThinkingOptions.find((option) => option.value === composerThinkingLevel)?.label ?? composerThinkingPlaceholder const approvalTriggerLabel = composerApprovalOptions.find((option) => option.value === composerApprovalMode)?.label ?? 'Approval unavailable' - const AgentTriggerIcon = - composerRuntimeAgentId === 'ask' ? MessageCircle : composerRuntimeAgentId === 'debug' ? Bug : Wrench + const AgentTriggerIcon = composerRuntimeAgentId === 'ask' ? MessageCircle : Wrench function handlePromptKeyDown(event: KeyboardEvent) { if (event.key !== 'Enter' || event.shiftKey) { @@ -207,9 +205,8 @@ export function ComposerDock({ disabled={isAgentSelectorDisabled} value={composerRuntimeAgentId} onValueChange={(value) => { - const parsed = runtimeAgentIdSchema.safeParse(value) - if (parsed.success) { - onComposerRuntimeAgentChange(parsed.data) + if (value === 'ask' || value === 'engineer') { + onComposerRuntimeAgentChange(value) } }} > diff --git a/client/components/xero/emulator-inspector-overlay.tsx b/client/components/xero/emulator-inspector-overlay.tsx new file mode 100644 index 00000000..df518d57 --- /dev/null +++ b/client/components/xero/emulator-inspector-overlay.tsx @@ -0,0 +1,200 @@ +"use client" + +import { useCallback, useRef } from "react" +import { Crosshair, ExternalLink, X } from "lucide-react" +import { cn } from "@/lib/utils" +import type { ElementInfo, UseInspector } from "@/src/features/emulator/use-inspector" + +interface InspectorOverlayProps { + /** Device dimensions (in device pixels). */ + deviceWidth: number + deviceHeight: number + /** The inspector state from useInspector(). */ + inspector: UseInspector + /** Called when the user clicks an element to open source. */ + onOpenSource?: (file: string, line: number, column: number) => void +} + +/** + * Transparent overlay rendered on top of the emulator frame when inspect + * mode is active. Captures pointer events to query element-at-point via + * the Metro inspector, renders a highlight rectangle on the matched + * element, and shows a tooltip with component name + source location. + */ +export function InspectorOverlay({ + deviceWidth, + deviceHeight, + inspector, + onOpenSource, +}: InspectorOverlayProps) { + const overlayRef = useRef(null) + + // Convert pointer position to device pixels. + const toDeviceCoords = useCallback( + (e: React.PointerEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) return null + const nx = (e.clientX - rect.left) / rect.width + const ny = (e.clientY - rect.top) / rect.height + return { + x: Math.round(nx * deviceWidth), + y: Math.round(ny * deviceHeight), + } + }, + [deviceWidth, deviceHeight], + ) + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + const coords = toDeviceCoords(e) + if (!coords) return + inspector.elementAt(coords.x, coords.y) + }, + [toDeviceCoords, inspector], + ) + + const handleClick = useCallback( + (e: React.PointerEvent) => { + e.preventDefault() + e.stopPropagation() + const el = inspector.hoveredElement + if (el?.source && onOpenSource) { + onOpenSource(el.source.file, el.source.line, el.source.column) + } + }, + [inspector.hoveredElement, onOpenSource], + ) + + const el = inspector.hoveredElement + + return ( +
+ {/* Highlight rectangle */} + {el && deviceWidth > 0 && ( + + )} + + {/* Tooltip */} + {el && ( + + )} + + {/* Inspect mode badge */} +
+ + Inspect +
+
+ ) +} + +// MARK: - Subcomponents + +function HighlightBox({ + bounds, + deviceWidth, + deviceHeight, +}: { + bounds: { x: number; y: number; w: number; h: number } + deviceWidth: number + deviceHeight: number +}) { + // Convert device coords to percentage-based positioning. + const left = `${(bounds.x / deviceWidth) * 100}%` + const top = `${(bounds.y / deviceHeight) * 100}%` + const width = `${(bounds.w / deviceWidth) * 100}%` + const height = `${(bounds.h / deviceHeight) * 100}%` + + return ( +
+ ) +} + +function ElementTooltip({ + element, + hasSource, +}: { + element: ElementInfo + hasSource: boolean +}) { + return ( +
+ {/* Component name */} +
+ + {"<"} + {element.componentName || "Unknown"} + {" />"} + + {element.nativeType && ( + ({element.nativeType}) + )} +
+ + {/* Bounds */} +
+ {element.bounds.w}×{element.bounds.h} at ({element.bounds.x}, {element.bounds.y}) +
+ + {/* Source location */} + {element.source && ( +
+ + + {element.source.file.split("/").pop()}:{element.source.line} + + {hasSource && ( + (click to open) + )} +
+ )} +
+ ) +} + +// MARK: - Inspect mode toggle button (for use in toolbar) + +export function InspectModeButton({ + active, + connected, + disabled, + onClick, +}: { + active: boolean + connected: boolean + disabled?: boolean + onClick: () => void +}) { + return ( + + ) +} diff --git a/client/components/xero/emulator-sidebar.tsx b/client/components/xero/emulator-sidebar.tsx index 2c95dee9..2118d119 100644 --- a/client/components/xero/emulator-sidebar.tsx +++ b/client/components/xero/emulator-sidebar.tsx @@ -29,7 +29,9 @@ import { type EmulatorPlatform, } from "@/src/features/emulator/use-emulator-session" import { EmulatorHardwareStrip } from "./emulator-hardware-strip" +import { InspectorOverlay, InspectModeButton } from "./emulator-inspector-overlay" import { EmulatorMissingSdk } from "./emulator-missing-sdk" +import { useInspector } from "@/src/features/emulator/use-inspector" interface EmulatorSidebarProps { open: boolean @@ -110,6 +112,20 @@ export function EmulatorSidebar({ open, platform }: EmulatorSidebarProps) { const [selectedDeviceId, setSelectedDeviceId] = useState(null) const session = useEmulatorSession({ platform, active: sessionActive }) + const inspector = useInspector() + + // Auto-connect Metro inspector when streaming an iOS session. + useEffect(() => { + if (session.status.phase === "streaming" && platform === "ios" && !inspector.metroConnected) { + inspector.connect().catch(() => { + // Metro not running — silent, inspect button stays available. + }) + } + if (session.status.phase !== "streaming" && inspector.metroConnected) { + inspector.disconnect() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.status.phase, platform]) useEffect(() => { if (typeof window === "undefined") return @@ -349,6 +365,12 @@ export function EmulatorSidebar({ open, platform }: EmulatorSidebarProps) { > +
diff --git a/client/components/xero/phase-view.tsx b/client/components/xero/phase-view.tsx index eed0d8df..965c9dc7 100644 --- a/client/components/xero/phase-view.tsx +++ b/client/components/xero/phase-view.tsx @@ -1,7 +1,6 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' -import { Plus, Workflow as WorkflowIcon } from 'lucide-react' import { cn } from '@/lib/utils' import type { WorkflowPaneView } from '@/src/features/xero/use-xero-desktop-state' @@ -11,17 +10,13 @@ interface PhaseViewProps { onOpenSettings?: () => void canStartRun?: boolean isStartingRun?: boolean - onToggleWorkflows?: () => void - workflowsOpen?: boolean - onCreateWorkflow?: () => void } const BASE_GRID_SIZE = 28 const MIN_ZOOM = 0.25 const MAX_ZOOM = 4 -export function PhaseView(props: PhaseViewProps) { - const { onToggleWorkflows, workflowsOpen = false, onCreateWorkflow } = props +export function PhaseView(_props: PhaseViewProps) { const containerRef = useRef(null) const [offset, setOffset] = useState({ x: 0, y: 0 }) const [zoom, setZoom] = useState(1) @@ -123,48 +118,6 @@ export function PhaseView(props: PhaseViewProps) { // CSS custom property for the dot radius so the gradient stops stay in sync. ['--workflow-dot-size' as string]: `${dotRadius}px`, }} - > - {onToggleWorkflows || onCreateWorkflow ? ( -
event.stopPropagation()} - > - {onCreateWorkflow ? ( - - ) : null} - {onCreateWorkflow && onToggleWorkflows ? ( -
- ) : null} - + /> ) } diff --git a/client/components/xero/shell.tsx b/client/components/xero/shell.tsx index e729c972..75546366 100644 --- a/client/components/xero/shell.tsx +++ b/client/components/xero/shell.tsx @@ -16,7 +16,6 @@ import { PanelLeftClose, PanelLeftOpen, Settings, - Workflow as WorkflowIcon, Wrench, X, } from "lucide-react" @@ -105,8 +104,6 @@ interface XeroShellProps { solanaOpen?: boolean onToggleVcs?: () => void vcsOpen?: boolean - onToggleWorkflows?: () => void - workflowsOpen?: boolean /** Number of changed files in the working tree — surfaced as a badge on the diff button. */ vcsChangeCount?: number /** Lines added across the working tree (for the +/- badge). */ @@ -243,8 +240,6 @@ export function XeroShell({ solanaOpen = false, onToggleVcs, vcsOpen = false, - onToggleWorkflows, - workflowsOpen = false, vcsChangeCount = 0, vcsAdditions = 0, vcsDeletions = 0, @@ -555,24 +550,6 @@ export function XeroShell({ ) - const WorkflowsBtn = ( - - ) - const SidebarToggleBtn = ( - - - - ) -} - -function Toolbar({ - query, - onQueryChange, - onClose, -}: { - query: string - onQueryChange: (value: string) => void - onClose: () => void -}) { - const inputRef = useRef(null) - useEffect(() => { - inputRef.current?.focus() - }, []) - return ( -
-
-
-
- ) -} - -// --------------------------------------------------------------------------- -// Table -// --------------------------------------------------------------------------- - -function WorkflowsTable({ workflows }: { workflows: Workflow[] }) { - if (workflows.length === 0) { - return ( -
- No workflows match. -
- ) - } - - return ( -
    - {workflows.map((workflow) => ( -
  • - -
  • - ))} -
- ) -} - -function WorkflowRow({ workflow }: { workflow: Workflow }) { - const status = STATUS_STYLES[workflow.status] - // Edit only when the workflow is not user-created. Type tracks `isDefault`, - // so user-created === !isDefault, and edit visibility is therefore isDefault. - const showEdit = workflow.isDefault - - return ( -
- - -
-
- - {workflow.name} - - {workflow.isDefault ? ( -
-

- {workflow.description} -

-
- -
- - - - - - {showEdit ? ( - - - Edit - - ) : null} - - - Duplicate - - - - - Delete - - - - -
-
- ) -} - -// --------------------------------------------------------------------------- -// Width persistence -// --------------------------------------------------------------------------- - -function readPersistedWidth(): number | null { - if (typeof window === "undefined") return null - try { - const raw = window.localStorage.getItem(WIDTH_STORAGE_KEY) - if (!raw) return null - const parsed = Number.parseInt(raw, 10) - if (!Number.isFinite(parsed)) return null - return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, parsed)) - } catch { - return null - } -} - -function writePersistedWidth(width: number): void { - if (typeof window === "undefined") return - try { - window.localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(width))) - } catch { - /* storage unavailable */ - } -} diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index c62e4054..6bb9e9f5 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -1461,6 +1461,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "datafusion" version = "52.5.0" @@ -7471,6 +7477,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -8915,6 +8932,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "twox-hash" version = "2.1.2" @@ -10325,6 +10360,7 @@ dependencies = [ "tokio-stream", "tonic", "tonic-build", + "tungstenite", "url", "xcap", "zip", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index 93597a26..6858098b 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -33,11 +33,12 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", tar = "0.4" [features] -# Keep dev startup lean. `ios-grpc` remains available for full iOS -# Simulator HID/streaming work, but the default build uses the -# simctl-screenshot + AppleScript fallback to avoid pulling tonic/prost -# into every `tauri dev` boot. -default = [] +# ios-grpc is default so touch/swipe/HID input works out of the box +# via idb_companion. The Swift helper (ScreenCaptureKit + IndigoHID) is +# the primary input path; ios-grpc is the fallback when the helper is +# unavailable. Without ios-grpc, touch and swipe fall back to +# AppleScript click-only which can't handle drag gestures. +default = ["ios-grpc"] # When enabled, `emulator_start` spins up a color-cycling frame generator # instead of launching a real device. Used for end-to-end testing of the # frame pipeline without an Android SDK / Xcode. @@ -98,6 +99,7 @@ time = { version = "0.3", features = ["formatting"] } tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] } tokio-stream = { version = "0.1", optional = true } tonic = { version = "0.12", optional = true } +tungstenite = "0.24" url = "2" tar = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/client/src-tauri/native/ios-helper/Connection.swift b/client/src-tauri/native/ios-helper/Connection.swift index 7c779d90..5fedaea5 100644 --- a/client/src-tauri/native/ios-helper/Connection.swift +++ b/client/src-tauri/native/ios-helper/Connection.swift @@ -92,7 +92,7 @@ class Connection { fputs("socket path too long\n", stderr) exit(1) } - withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in + _ = withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in pathBytes.withUnsafeBufferPointer { buf in memcpy(sunPath, buf.baseAddress!, buf.count) } @@ -266,14 +266,17 @@ class Connection { private extension Data { mutating func appendBE(_ value: UInt32) { var be = value.bigEndian - append(UnsafeBufferPointer(start: &be, count: 1)) + Swift.withUnsafeBytes(of: &be) { ptr in + self.append(contentsOf: ptr) + } } func readBE(at offset: Int) -> UInt32 { - var value: UInt32 = 0 - _ = withUnsafeMutableBytes(of: &value) { dst in - copyBytes(to: dst, from: offset..<(offset + 4)) - } - return UInt32(bigEndian: value) + guard self.count >= offset + 4 else { return 0 } + let b0 = self[self.startIndex + offset] + let b1 = self[self.startIndex + offset + 1] + let b2 = self[self.startIndex + offset + 2] + let b3 = self[self.startIndex + offset + 3] + return (UInt32(b0) << 24) | (UInt32(b1) << 16) | (UInt32(b2) << 8) | UInt32(b3) } } diff --git a/client/src-tauri/native/ios-helper/HidBridge.swift b/client/src-tauri/native/ios-helper/HidBridge.swift index 63b8b249..725f9151 100644 --- a/client/src-tauri/native/ios-helper/HidBridge.swift +++ b/client/src-tauri/native/ios-helper/HidBridge.swift @@ -11,6 +11,18 @@ // the caller falls back to AppleScript-based input via cg_input.rs. import Foundation +import Darwin.Mach + +// kNullPort is Int32 on macOS 26 but mach_port_t is UInt32. +private let kNullPort: mach_port_t = 0 + +// bootstrap_look_up is in bootstrap.h but not always bridged to Swift. +@_silgen_name("bootstrap_look_up") +private func _bootstrap_look_up( + _ bp: mach_port_t, + _ serviceName: UnsafePointer, + _ sp: UnsafeMutablePointer +) -> kern_return_t class HidBridge { @@ -29,7 +41,7 @@ class HidBridge { } let udid: String - private var indigoPort: mach_port_t = MACH_PORT_NULL + private var indigoPort: mach_port_t = kNullPort private var portLookupAttempted = false private let portLock = NSLock() @@ -43,14 +55,14 @@ class HidBridge { portLock.lock() defer { portLock.unlock() } - if indigoPort != MACH_PORT_NULL { return indigoPort } - if portLookupAttempted { return MACH_PORT_NULL } + if indigoPort != kNullPort { return indigoPort } + if portLookupAttempted { return kNullPort } portLookupAttempted = true let serviceName = "com.apple.CoreSimulator.SimDevice.\(udid).IndigoHID" - var port: mach_port_t = MACH_PORT_NULL - let kr = bootstrap_look_up(bootstrap_port, serviceName, &port) + var port: mach_port_t = kNullPort + let kr = _bootstrap_look_up(bootstrap_port, serviceName, &port) if kr == KERN_SUCCESS { indigoPort = port @@ -66,7 +78,7 @@ class HidBridge { func sendTouch(phase: TouchPhase, x: Int, y: Int, completion: @escaping (Result) -> Void) { let port = ensurePort() - guard port != MACH_PORT_NULL else { + guard port != kNullPort else { completion(.failure(.hidError("indigo_unavailable"))) return } @@ -116,7 +128,7 @@ class HidBridge { completion: @escaping (Result) -> Void ) { let port = ensurePort() - guard port != MACH_PORT_NULL else { + guard port != kNullPort else { completion(.failure(.hidError("indigo_unavailable"))) return } @@ -183,7 +195,7 @@ class HidBridge { func sendButton(_ button: String, completion: @escaping (Result) -> Void) { let port = ensurePort() - guard port != MACH_PORT_NULL else { + guard port != kNullPort else { // Fall back to simctl for buttons when IndigoHID unavailable. sendButtonViaSimctl(button, completion: completion) return @@ -317,11 +329,11 @@ class HidBridge { headerPtr.pointee.msgh_bits = UInt32(MACH_MSG_TYPE_COPY_SEND) headerPtr.pointee.msgh_size = mach_msg_size_t(totalSize) headerPtr.pointee.msgh_remote_port = port - headerPtr.pointee.msgh_local_port = MACH_PORT_NULL + headerPtr.pointee.msgh_local_port = kNullPort headerPtr.pointee.msgh_id = 0 // Copy body after header. - body.withUnsafeBytes { bodyPtr in + _ = body.withUnsafeBytes { bodyPtr in memcpy(ptr.baseAddress! + headerSize, bodyPtr.baseAddress!, body.count) } @@ -330,9 +342,9 @@ class HidBridge { MACH_SEND_MSG | MACH_SEND_TIMEOUT, mach_msg_size_t(totalSize), 0, - MACH_PORT_NULL, + kNullPort, 500, // 500ms timeout - MACH_PORT_NULL + kNullPort ) } } diff --git a/client/src-tauri/native/ios-helper/Main.swift b/client/src-tauri/native/ios-helper/Main.swift index 83d454b8..2ba788f2 100644 --- a/client/src-tauri/native/ios-helper/Main.swift +++ b/client/src-tauri/native/ios-helper/Main.swift @@ -10,6 +10,115 @@ import Foundation +@main +struct XeroIosHelper { + static func main() { + let parsed = parseArgs() + installSignalHandler() + + let connection = Connection(socketPath: parsed.socketPath) + let frameCapture = FrameCapture(udid: parsed.udid) + let hidBridge = HidBridge(udid: parsed.udid) + + // Wire frame capture output to the connection. + frameCapture.onFrame = { [weak connection] jpeg, width, height in + connection?.sendFrame(jpeg: jpeg, width: width, height: height) + } + + frameCapture.onError = { [weak connection] code, message in + connection?.sendEvent(code: code, message: message) + } + + // Wire incoming requests to the appropriate handler. + connection.onRequest = { request, respond in + switch request.method { + case "ping": + respond(.success(["ok": true])) + + case "start_capture": + let fps = (request.params?["fps"] as? Int) ?? 30 + frameCapture.start(fps: fps) { result in + switch result { + case .success(let dims): + respond(.success([ + "ok": true, + "width": dims.width, + "height": dims.height, + ])) + case .failure(let err): + respond(.failure(err)) + } + } + + case "stop_capture": + frameCapture.stop() + respond(.success(["ok": true])) + + case "hid_touch": + guard let params = request.params, + let phaseStr = params["phase"] as? String, + let x = params["x"] as? Int, + let y = params["y"] as? Int else { + respond(.failure(HelperError.invalidParams("hid_touch requires phase, x, y"))) + return + } + let phase = HidBridge.TouchPhase.from(phaseStr) + hidBridge.sendTouch(phase: phase, x: x, y: y) { result in + respond(result.map { ["ok": true] as [String: Any] }) + } + + case "hid_swipe": + guard let params = request.params, + let fromX = params["from_x"] as? Int, + let fromY = params["from_y"] as? Int, + let toX = params["to_x"] as? Int, + let toY = params["to_y"] as? Int else { + respond(.failure(HelperError.invalidParams("hid_swipe requires from_x/y, to_x/y"))) + return + } + let durationMs = (params["duration_ms"] as? Int) ?? 300 + hidBridge.sendSwipe(fromX: fromX, fromY: fromY, toX: toX, toY: toY, durationMs: durationMs) { result in + respond(result.map { ["ok": true] as [String: Any] }) + } + + case "hid_text": + guard let params = request.params, + let text = params["text"] as? String else { + respond(.failure(HelperError.invalidParams("hid_text requires text"))) + return + } + hidBridge.sendText(text) { result in + respond(result.map { ["ok": true] as [String: Any] }) + } + + case "hid_button": + guard let params = request.params, + let button = params["button"] as? String else { + respond(.failure(HelperError.invalidParams("hid_button requires button"))) + return + } + hidBridge.sendButton(button) { result in + respond(result.map { ["ok": true] as [String: Any] }) + } + + default: + respond(.failure(HelperError.unknownMethod(request.method))) + } + } + + // Accept a client connection, then serve until shutdown. + connection.acceptAndServe() + + // RunLoop.main is required for ScreenCaptureKit async callbacks. + RunLoop.main.run() + + // Cleanup on exit. + frameCapture.stop() + connection.close() + Foundation.exit(0) + } +} + // MARK: - Argument parsing private func parseArgs() -> (udid: String, socketPath: String) { @@ -34,7 +143,7 @@ private func parseArgs() -> (udid: String, socketPath: String) { guard let u = udid, let s = socketPath else { fputs("usage: xero-ios-helper --udid --socket-path \n", stderr) - exit(1) + Foundation.exit(1) } return (u, s) } @@ -53,109 +162,3 @@ private func installSignalHandler() { CFRunLoopStop(CFRunLoopGetMain()) } } - -// MARK: - Main - -let parsed = parseArgs() -installSignalHandler() - -let connection = Connection(socketPath: parsed.socketPath) -let frameCapture = FrameCapture(udid: parsed.udid) -let hidBridge = HidBridge(udid: parsed.udid) - -// Wire frame capture output to the connection. -frameCapture.onFrame = { [weak connection] jpeg, width, height in - connection?.sendFrame(jpeg: jpeg, width: width, height: height) -} - -frameCapture.onError = { [weak connection] code, message in - connection?.sendEvent(code: code, message: message) -} - -// Wire incoming requests to the appropriate handler. -connection.onRequest = { request, respond in - switch request.method { - case "ping": - respond(.success(["ok": true])) - - case "start_capture": - let fps = (request.params?["fps"] as? Int) ?? 30 - frameCapture.start(fps: fps) { result in - switch result { - case .success(let dims): - respond(.success([ - "ok": true, - "width": dims.width, - "height": dims.height, - ])) - case .failure(let err): - respond(.failure(err)) - } - } - - case "stop_capture": - frameCapture.stop() - respond(.success(["ok": true])) - - case "hid_touch": - guard let params = request.params, - let phaseStr = params["phase"] as? String, - let x = params["x"] as? Int, - let y = params["y"] as? Int else { - respond(.failure(HelperError.invalidParams("hid_touch requires phase, x, y"))) - return - } - let phase = HidBridge.TouchPhase.from(phaseStr) - hidBridge.sendTouch(phase: phase, x: x, y: y) { result in - respond(result.map { ["ok": true] as [String: Any] }) - } - - case "hid_swipe": - guard let params = request.params, - let fromX = params["from_x"] as? Int, - let fromY = params["from_y"] as? Int, - let toX = params["to_x"] as? Int, - let toY = params["to_y"] as? Int else { - respond(.failure(HelperError.invalidParams("hid_swipe requires from_x/y, to_x/y"))) - return - } - let durationMs = (params["duration_ms"] as? Int) ?? 300 - hidBridge.sendSwipe(fromX: fromX, fromY: fromY, toX: toX, toY: toY, durationMs: durationMs) { result in - respond(result.map { ["ok": true] as [String: Any] }) - } - - case "hid_text": - guard let params = request.params, - let text = params["text"] as? String else { - respond(.failure(HelperError.invalidParams("hid_text requires text"))) - return - } - hidBridge.sendText(text) { result in - respond(result.map { ["ok": true] as [String: Any] }) - } - - case "hid_button": - guard let params = request.params, - let button = params["button"] as? String else { - respond(.failure(HelperError.invalidParams("hid_button requires button"))) - return - } - hidBridge.sendButton(button) { result in - respond(result.map { ["ok": true] as [String: Any] }) - } - - default: - respond(.failure(HelperError.unknownMethod(request.method))) - } -} - -// Accept a client connection, then serve until shutdown. -connection.acceptAndServe() - -// RunLoop.main is required for ScreenCaptureKit async callbacks. -RunLoop.main.run() - -// Cleanup on exit. -frameCapture.stop() -connection.close() -exit(0) diff --git a/client/src-tauri/resources/xero-ios-helper b/client/src-tauri/resources/xero-ios-helper new file mode 100755 index 0000000000000000000000000000000000000000..ef65e427407bce2cdcb86525b0f318f0ca405841 GIT binary patch literal 161392 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%0Du!TKQ=z#Vy%^f>Vl@6j#_nnT< zy-{>WFW~hCZpRp183&IsKb3um5;t!8Bm1JiVBd2C^uD$C#O6A$`R2LUY+^5k&W{ad zZdvpG7t2l7?R%}Ri;W8&n4!W`b-VF#k+ygZ^t=&hdd&!2#@?`I=zl$OABJ!KJjVEy zDp1JyV$b_GnebPsn z&#i!mLYKSP+kJwcl#@BBtRvMj_Vi;g&o$|9$c*G=9TD8c z4wAX6^yQyBYd*`^<1KLYe4ja77BYI$d|&S+SLLZC%=dvW@!nEjKVX*G_WU!SaUO!q z?_3%lrJX{XuSJ@OyTpM&Iy zrfou3{GmTdJb*j*w;2CqPM-meLYv<}muMJXhd%`le=-iY6}}(tNVB=b$9M3LoPu6| zaeNWjJ?-)FGMeEvKU_)wni6ceQTmtd zXLtKe%Yv8i9~k)IPu>?Tdj;a5mH5buMYeqB1~N!9e1iiu@W8bV{*!9k1}W?l_%rqI zIzaptbQ$~6y}?WWxm=H#B9mfcE!U}SAHVd~$@>L*IQs?Soc#iE2k*wm9oN2RK&}T} zgFHF%Betr@Pd0KO`vppz{Q?hDkIbXs#YTDs8)+yqBKb?Ok;MNXW2V$)kDvCwb-6YL zo?{=41YQYaUfSIf6WTFeGgI~`T2 zSWg;l^GPh%RO*oUC)fT;$Ddm;Q0bU?nd8X1rpM>Y7WuxYsV%uP9i)w;3w^<{I*%O_ zB6ETv$m!B+Rq$uItf6T59r6FZaC!l!nD}LZHIo=-foJP1!MP*MbX7Gz6Ff|x%7&KX zg$5nltE?>EE9h0)-4~Yp#n>j5t(Z@3Hp6JW2B$kP_ zpj~FYw?FYx{iPlgxN&Ew>BMR+%3QJehW6*gthm<`>b#3gcu)i^<`0Ew6IC|r9HY@m zR|w9UX9vAkr*UoGt%8zo7Wrgd!6dJ)hkE0rjftv1XIv~&D^?BE9&E`4J=jc@C$erYay&{UeB2sV1w%K*McuPC40fzYAi@R5i_jmV?zq%b}gg&e* zjLtEd$C>2Ek1`;Qcs}#ORhwLXn^C+M8)MJ6ZCY;P&Co8$t)tK_C#@5} z@3~I9+&B@*lLk*+7(#Vi~)=p@vPw{V8x>BNgm?Jt0jJPq2tf6<6Yt<{$Tpo)OQ#=?rwDAarWxzwp5&Bx20lGU;ytW zPP~bF+S_uEy`Rfne{XM(tiiLFt5jkLnS*UbC*XG(Epye~*x0fzUq(Dt9r09VRcm9A zJtA}8-qu*p9+AE55g7>%vaiI?cqe;PawwC}{**fQry<=In_V1`<%SDm+}I!H`${kC@+SFsOf)lhHn zq&Nfem7N`fEg)mR#1zF_PeDI__ij8(u*3%@dPJ_Dg`UuwJ*=A6?Bg61c;Op2zQCS; z-{j^AIe9{Z0^7;s!+$t~`987#+V;MkIz$E@B)^sc-fy8@IkYQ>c8UEcK3h*kOZDv% zrU3qq8Er4X1{AjRc3i8Ei(U&2OedgE%ij!2wfPm z#vYz!F!%F7`^b4xUHL$NkreTPuI5^Npb`_{`aoZ&pRM3|S3c0X2@@mJfi`4!|9`Lx#%BV(H8Vdz}O616X5T1;RNZJEh+F3-HAvx;K^qj(lA zOTuHRBmU2mSl^2;AL{IrS|N3`wb}cmc<$N^!ed>JIPzT4zRb}5i;X(vwZnwg z30hB!4`eRzAD%AlvDdEbwQBo1dacu4>(}j|PPZMWu2bT(T)(0(99_5Tf4Q8x{n(|y z#x9k5yQ;5C8tv{#qlgy5w8xk&oKyBYe$jLl8g_M# zTJ#xnLc^oTvy4YtHsG77%<{B*R8Zf8V~6{{P70@$W;W}0d-e6ja-H(_q_E(##KGqt zaPjjC-Ey&=7n!SrZ%NG2j$LKLmOW|WPxue@N7GE!u3bKTpEkI7i%du3-Qga*H{TM$ zn`@1CvV->|2k)!Ec{;!1TgJ8HUFzVCA6w>enl3Kx+fIYK+ZVNb`!WK*C$9mGTwNzI z1tR~lzJ97bEfS9)F|ZOJ=+d0?^e)b49WN96#>~mm@%?gUM7nckL~c5=f*gt;QPy>S z1b?z$8?JSeYujxE7a2E{jsZ@#KH=UcvR9lLFuL3O3m@c}*j4eI{gzPd3FgcDGkoDC zJQq7obe#)tCC{WT7v4S>p2`Xs-E4Sw@yukc!^}B5kdkf$6Z>Jm$$iE}_{zK*zIh1W zHL;(xZJ*x%)>pNXXNOsmVDFzV$qLjBC*Fi{cM$MIZkzFui~jl@_ae`~)oG$tq0`a3qE$}X)&2{2?}f`-KX&svU8du?a#tr2 z3j-aW5ncA00e?M=_g=<*_OxBa`EtuRJ9MSN+J((qq4UP6Il)bHh_?Zz=r^%JMYmlf z@OgGGX)%5XX|p-ZoWcBYjIHlv%!$|MbBNFB3w%DZrf8;d{)CZok)21*#~NtadNJAR zrrS3eTqc3jL~zT)7GeJ@{z@&AD`h`z$F*6Zp-G=#h5X4nY=-!Nc{Y~5wf>f$H9v<> zT>2IKT5B5T1nQRCGNXBl?Um{BG*pu3L2G&@nZ>PNIIH8%qOzEx0)y^`AMYQLP zb9mO|ofxcTjV`G38kXo{SwC^l?#w06z0g$Xc%426D%m=VXIkE8Bk#;Z61f+=hmwkX zl!%=9N6C8dzT7H1*7zmXw#3$w_FqL^w%jCHeJLYroyXr*!4vek(hT-69Jps)GG}M} zGehkx>yf*zZV>C&JhNr}0^Rl$+Wr`A+!U|&*RfBylsyWS@VSV!o}p>POx|^O^9hH3 zGuWp$X9)3l;A6~{KALLP@lJ3)LMk|?kW#<1S1!q_=ROmjnea@`8CKpKj#ub7=RwHM zKx-)PJiK$ydz|6Gk@ZG_`A1VRcTctc$@OEgDzKc`+%3o3B(^TWU6VYEcmLAyR0$d% ziLIz)EdqbRz7FRRUTL36D!SB_2ThCJwj2m8^nK1KO`GB#YX8jhR6yiI=BUwoU5?WC z#W&iyyp)l=9r>SZJq3=!v%l*!(VAj7WeuATSMPM%$0M9!)DvPHMh%sf8xkLzGq!3GIeKvwKJ|~?mD@bad{&$Bx8@@bt~nM zaTf4$Piljd^RYhKGywTxe`0A*_P`LUVgh3`KJiys?@`Ns-aL~svYxb_v!A>3Ot)o` z$k;$F=hBuRlSje3Ew|+9vsMeZm*>}#y884=u7%D&qdg*rcAqC+o#)v62@<;|Ypeo8 z_QhZ9z?ej;#~;cGEY|U$aiW#Y^FrqM%e_erTkz3r_6%;oM^_kP-PNuu!McJxvi2*u zp3AkoW3TXrSF}uuoZIJAx@UOP*Bo6u*!qFapI}`=I_7BXSl#a1?RNL)ng2-aSlOe# zfHHOXo)V#(+zW0CdFEbAUy?j>e=})?(;q|@f5Lr``zFeZA4ukh(Ld1>n~B*`TQ+RE ziM*mqH-6amyvQ62(xNMwuV2_cHl`!1az>e!(P6%z>^nOLxFQQ_UV-z-Cb6ZZkA=T% zd!9v)9UG>yUlCjwT{djs?DM5PzkcBn^vsLwe_Y=qw=su3*0P3E#+ijOzI^DEPh)Rx z2J`R}=m5cI80BUC`X9Wv$Fo5!y@?J%-#WMmZQkU)q`mFL z7A-fzqh(S?^C&^fs^EL0IWn%;XJ#i_D|G&3>q4C- zvChzOzsJb^Jt^!%oJG%q;J== zcE#LpN54kKIc&Uo>}qp7ze9JgyVNk>T+Y3~QHj3rG0F-Ljq!P5v9phIU$HW=S-(Ha z7yin9&s<%u>sFRLEBd;e@n6<6<$4O&LZ5W{j?jFxQ^w563saA8l9{*r7B&0jtJKz`-ozq`*1?B~*_MJAVm=L+4o60NtmU&&8p z77sDioX4Tbx;W3*>tx;KV5(jxt2Xa$ZZr(etVu5!GM{JW-F4OOeK+ns>W;^C+po6k zc>#DX{GD8vo&x?9;;vYS=>Y$2&YXybKb!k@_}AF*Hv>=8HNzMFjldVW|0pl~+m+We ze@y!&j_21rZ-JJ2oc1~6w2IZ^^myiVGKLEbvo$wt7_2*TKB?%11L!LIOteAPe&@V< zGuD15V}iVU1zzYnf07sez2?JU%BAW(m2$4*DFNaXw;u6M4yw)uE>H5$Lb4plY0 zeg81JS?9Co`l*)iOXtDoZVwN6muUkGL0I!nwAjgRN^wZTmOT zny#OvT91Sw{qr^W`sf1hbY=8LWm<|gv&TAA9+T#>!|daj7wbw;4Pp?$8n z!PQ4@b|H<=pwM;vAyKi|Fgu!OPtP^B}bAT9?M48^vELGUTzoqKs>g9pu{9 zp@~*K*EYXI55!w{;a3xWhIAdt)`vQkHQSE5^k4lWYmvgcJ>=6oMmLF1?Tx7S>F`DQ zM#viAzTB-N0y&%^^cr$0^SEDAPTJ=Ba88%E+{xRvQb9lan;$(mBLV!y54gj~7Wq-7Ce31Q4k`JG&`|Lya89tU`zN4|x zEr;Jy?%nzJM^SATK2?%WXk;L-_Pl&|<)%(~HamG-e`MQhiu2hbXO4;Ak$40?FTniC z7#YY~q4sBB160IB>^{*euHS(2y6sZ0_>iLOUrpVo+H;i?tDe{G#YGgLN0C8$ zpKz*`>D6RA{f86)`v>Z!J!^wNAvNY{a zelB!McVZ;uT7J7Fri8eTGAFjgjSXO|*J}XsUa$ER6Y#2B^IYaMGj)FA10wMrJCqNd z%(xF0b|ZEF%cEj&yHcGD9oppel~z^!{KH(2+SC)ob2gxmJ$?+&=%B*@|X zN~5Z^QOfip|B;^B7uP|*j{2f&{VM+ATFQ+S{6EM=za}|zbM~O?vn!IU4|LlStuIMA z2Rh+5*X9N{&}LcVm9p3&+8_H4@8y|{7k=bK%OCwSskF2?k+l(5&;3EeXy!cVx%Y4M1KO|TWazBZmDRz(1(eB}^jqP@c z61#_a!`sY9#4d5~XSdH!60BvwIiFuwHq;dQ%1Jg3<46Y^hkIq*@wkB=w%0G+ z{28o&rt@3c(3Z^Do4kpi+^6yD>@U4$>jTwC&e_`dch1qB*Vi7SBrYyZB{r-8CplYT zIrgs7^ZrE3;9kB-Ap3b_TzQf*ykp;cyp=C><(aD+-Ln-d4y~!K+Rj<^d^>`gWtJ0L%)#mpqC?;=Q?M=>!RRr5Jh z=Yh8y{o4*|+-ku`+Pj5R+7vG|rkuo>uArR#J%vd7=QuQzye4^R-{Mm8tNxn@{x8aK z{seJR8S_HyV-x;H^HS`A^ufM$^MT>=Q1ZyyN%VS&_)YOeX?wVcyv0L&>zcTiYe|cB zd3#;MUCSUYfIXtVY~}{-b$5ol-ot)z2X27_OWNUHU)}}WX#DHI%MXku<~2Xic_ZuS zr>g7xsCwM%3%aPMvz&@d+G8N&yR%mJgunuav~;yAZLP}AXAMT}_l1S;=#;hk{7O7pE;qr)Bt?4V#*+0NG8H`U##>6sk9;7&X`749*hIQ0g*e9%MJkp0T z0NN0>u48Otf#9ZMAV7t@9^ZN#sw2uHqYs=enLfq;OIH-mYyYI3!rTIZLp{cY*a7oNyU#rNtBM*P&z4(|JN80y~%3k~&M{jqvt{Z)>UODAOZtI76 zv1htIYw{?2M%=S|#GVu$PGWC;1+ja_xXxzn)%4DMJm{JEIO9O|J+#em_RqZteCeyo z^iQA1b5rF$8)sHk*AHV|dnNJ|tNN>);vpIQBnGjFb5fdSan1t%cUgCRfOZsk*#G*` z!kPqlqh)x-=yt!X&sndWneLx1c8m#(3B(p=P;QlTCYF0vjO=AIjf8pK*`Fk5#jqzs z@1K-;<_Y4!Lcdb@#l-KGV*Ok8Iu8>b$a=HvW0mjmq*&dUTcvOgfvb}w?@h#NhkoS? z&Xm+CU&M7H=cC*HMq;sOYcXYaLW2t2J`B!*ZRdqJ^?L$|LEx8apdQ>eJ=`D#9kPbIxhh?@%u>3U8Ogn z*~CZW`0>_u<;NR?AMX{9Jr0hhtX*zY@Vm;5qgrdn>LxP6W0?f{LNnU?#nCtH4oPoo`wQEc*0h=XuzM zv4?x+$!oW4vhV-GeN_*8ta)Wvc5o+fit#g~^1Nateq_>2vHgrO?e>tiMkxhY3%$jcV-z5KacXm0(yvDn+^dZ5&QkS)Do4YA%uem2$ zoAvV)t1rCKerIqgMh+^F13z-0I7>XEFW;7&@>HV{V}Dyx>fZy57gwf;zZo4eN@8i* z193*+t50oz$T053-+U&i_BVt77Rr52xl`f05nP1!cj+`m<7}Q{jFfx}$tV8jEb8ds zf5z_kF8}j+l$G(ZWkC$%XV>TQw%}V1lHf zb6zVkCas|~6%M^-+v8%R&hcVP6iv=*_9V?ao3p!*Gj}?Uofu|)<^=JXCVT4#V_W)Y zN7`_8;XKk3&TgK0i|Ix2f$BTDBm#7d6xKqT{ZV5TcKgzUL!`qRS+wtY3WAk>vqx@iF z$yaHCh|C&VW}{(!2rR|9Xf6)^gKsy6-Z>P(gYPCvztl3Tzo8hx|J^7&7J{9xXw^Gn=<=poM4*bw6TGGc4l^NzvJ8iK49 zbo}<4oE`1T$+({15hLI=_KRObVgzJu-JOSmSBLXY2BU*sM#oA2_ySu}?4@X%pS94h zyP9LT_L9I8d+8wU$md$@CAt2-_R<^FxtE{VOSTOkv6nQwD0}H2LM!^!e|2h~i(qms zINN%Ba0eY5Vx6d?4wwFC>)2d@(M^qejP~D+4leR`EPJwkj+7lm+3(WN%ze7h&k-0y zrT$L(S;};+pCjcGD3?Ca*3W;1Y{zWwuK_>T<{QI(aZkr~d;;4MI92rh z)wI9DdqcC}lPP_F_qcZ3(e3+Ld?zPJ85fqwK*h?G7t@6v_~$e|`4)}Xk23Gm_9O7G z=UUo*iRAxhNBgmI<#o+6CzJOV@%|EYnb0MfYnLAl%%xo$vX6eAV%{D#vd$hCE_RYMG0&C4Y>}$vm`6Taq?#fZSzuPFmyX@|AI9j%7^7dNU%lMpOXAcMtk-yl=~UgDaiy!VGlG{pc1G~&OWCf* z&UTzm8apFp6=i*$aC)rnkjCk8-ko@7P4ysOYvbp9nwXOkw^Lu-!VMp*eQKSe2?NoTrc@g*%y*6kvHiR^GSaW zP0t{WUcXl4NZy~sv96sbeqhi17UHT;%!^^q zW;gbccKq&8Ch?td{A4eK*b(%r_VJf*2ZWQO&L6uKJGKR!OS{Y79r1@JSgFL2mwm`t z8`vCKE0V0a>|tfDZJ(`-T^lCM%S>|+%)MaOU!S25>U$XGAu zig@?84<1>_C$&7b4n z7O(d+56b7334^ zz2x;1zhK)TDb`Bvv%zCB>*@0TZtiKrDQPCKj)IfP{Nm^E7ygHjFwc;49XrpL4QzPV z=X~tO$;jL!>RXKg7n?56eBt@0{K#j{8RHai6g_Xn!+t5?$|ocF)%!hRyM-)iH0o znYKJK)R&dQoGp!WQf*y|q?;78JUe<$s zbe@3pspH;m*upWK=ViythUt&=#Yo$Rb7n1lF%+-D?dJhvKN8R9^hM&7IiJbymr2(D z=xaM}{{rN;l<|}E(|4ujH@}d{S%LZa>iO`yu~}`Gu@-if+O@|yV{og)pJu2r$DZcA zbfTVC50YPV^i7q$SIWIB z_t)3d*T_BP{wepRHT55GUi-%?@al(@{YYh}8w1O0pH>0IJ`p)f!eES)Gn`!AA%64H zsP@f{YTwML>m!HSHlFy{EBa)g=#x6u^^_6ODTjF9M7{5`=F|LDUT`3Mb7?jWII`dA zS>9FUx-nFPtQWY?M^5JaP|nL@zk}B-mwelKE_5yPs$KQ;h5GT(mTwi*jaSdFdRJv_ z=G!ZqSxKu=LcVxwnEbu`pILCP8PZjh@xxXC|dT4y50=kPDRs4 zcqhJ(FX%&e((mr#9Kz7hh+ceijOe4mRs-*wNN<2{#c67%*nDzd#J%XnCr683OesHt zUUcPk*G0Cx)?L)KyuL*rAYSnQnY{kO8S9uQ7dErM;yvg(l=i3#?ehrbemM6g?dyJ? zDvalhMR}ISvr!kQo%O6~dW^V+*U#lUc*v!qANo!QMdQ2u!CnaYqLJ`%eXH%wQ{fdkQIS>gl9|6iNItHrcm@+Gvi;{ z%8dw$kY`2~=UnS`+vIuo^G7Z zwD~i}l~k?22U|bnx#$_*?YgU9^9!7xT0`vH^OvBzC!o70MCoohx4RU(7h9Eaj&bpG zNg3Z7BxPLuN>X(HVM!Smk4ehdc0y9d#WrVL^cwcK$oG5oxY$$fDc4u-85iS?Y^}SK zIWHI8oi4hY{xHJnzZuweu1+w)U&(p)PXF!Ay~ylj_A0u1rW?;jVK2LS<|%BQ=>Gc_ zdQ1B65%y84J`q^QxffVx^WKHk$TQaZ+GT7G_O2^q8c$@b7d((TkllX=>NBS#zR>Nz z2JZ!5mv?bmt_E8_)#YSPkYvR~$(q4+8Fr`0+NGzEHTj00&_#UVt;;UQwkp8J%E#8a z4BK;Zuw^8+*=wGYY%`(5|IOd;;DuYC(EM@eDfe>yXw>y1QP&SgU9XF}ekkhtfvD?M z&UL=nKXWH{h(C4bj+yeVmhbR_&zJlpugQL4_bemv?RS`K=Bhw{&7Y3vM7+^0vZq1z z)gJ49v#{`gU!cnj3s{W?r!D2F{Tt&Ti8P_E?_hYpnn;`HlhcF4ZzW zVl(pwd3KPrigBiSr4g(rhUpe3j)}AFwV%n=LBAxQ=oeWdx|ny7SSHTz!j{iKZqtcl z%5h?u&XMy_@o{Ilu}t{r3pmFjlUOG4(SM&DWAQ68RM0 zpTM-w)lRf76;+Ab_%>o8zbR+T+rwV?di306u8%HI ze3K-;fp7d2enFlqjaiTPF=jq~K0ht9|7Ogh9*N%>1#gZ*mtx^<%KRaQdE1bYnRACI zbHR{anYRoPnKu4Me6XpOf`4Cuk1>if;OEJjU5eE`iub!%BkRn28E=Xyw}Rhx_RJh# zFez9H-zA3g0r;K?-%H`UUc+O)BJp_z!|ZRF$u}F>+mImRiD?X+*PXFY;?rclFES!) ziQU3DkHFO(_tt@%Tuw^Z=jJ3r6z!P7ItR>2M^^Yak z=hPqTlfQ0b+O+2+UI!lQJih~u;jAaV0eo3c6kkZ_36VM3D?G^R*DiB|**h-ZeoaE| z1`?yg+99$tRpejmZae=b_A7|oJtCY-wgJw@(_>E^ zJ`EF|%J>#X8r}8^r|r@wXLP}r68Iwgl=#AY*~^4~LH70wU*uaKE^j2hFq${3oY+D4 zdzxk11|@cou`-%B5<_@4d-p|dz95Dv(kD}_Ju)uoJ}EJTZlAPc2xFm*>(3Vd-wcN~ zS8LkXF^E&Sei%P~be{}ie>_1urBB{M+u_$fbN9)8Qu^Z&yFc!5`lJ0_c<|XtdAC2_ zF0k+?Tqksl>W>TLdFTG9&rsnU2l9p(U*%g2t;?=P2Twy6UxiMdif+D=HN7dsK3##% zz8u|MF#oGd*{i%VMvtequg-}%ls*p3L-;e@aqj!1SyA8Nni+$<@*S=lNa@2nRB&<_ zU;fVM@kq|jJA?MrB8%v!9jZAuJPF(-znL?peH@cA8pbmx!vN+EiB~g7C0?yRylsb9 zkrTeqg~uAHJ1z=5^4oarIvJ)Cm{mgSVcd$oL|9(s|K3)m^0E^y^Au>+B{Is z(M(52h!15qsoBqWN1ak*q%WX*+RyOi`HLoN|D3s;ota^dF#D^kSySvUZ8T$i+4*g4 zV`T3E&li~)k#m6azXuLQ;9EDUY~X~iN$LO6H{TLmXbazx%dSOtCWFt2Chye+;GRXj z#OLYwc=0hyzBuv;F47Lst!e$#9i*d$1}<%M8-X9kJDs+(bn*SVru}9?3 zve%=2*sD4wQ1U=s4eL~U1;y^opnQ__MQqjnq_)n8w|a7|*Q9ygjca>7EZ%Bk-*mC_ zJ><{1wqwd=jX}OCn{0VGmq}tMKSfthR=@cF*!vdvxQcrJlQeAu4X{9gQeF!LC{XHd zUQH`vHk+ggBu$zPX?VzXvwM3Dqs^9w0Si<_ELgE3VnwdjTCpNv#e!8U7DNQB z+KXH)h**)U)myb9(*O52^Ep0Q)Q+5iut-NUu^zIW*F)m{zU5v3y7&8A zln&S5_QP*GWa<4rnMqHy-f=3-lR%Vrnp15@A0g8#*N)OyfW}ZWAS0hkPTqv)9^{GE z@ndZb@wb#`Xi?OK|T| z-s$`SSvODI^Z>#jebV9j&m%*X$>#=mvw0RrhA0jiJR85%-!IW zmon6$Z%?ANw$|U3QasxIwy8XOAFp+&(+cT%&)FCwAHV7QB5r!FJbu$^q*wnPR<3W5 z^kF;#|MY&C`c4WjD#9@CyM6F`=I^=6#P?ipLRKFZ`6r!-@Xn?&{PCNvhRlALe3kP$rM%n{W=VC}){Q8lTbgZYGUQm^(i>sT_O9VZ7gsJ^HSl`lWw- zuMGQF%X@4b?-TbT4cn0hDud6XuBGd9(Cb4p0RxzAT1UB5Lv3FD*+=)kCRI%neR zc;`mbk@WpH__ZD-p?ToufUGncARB*KApE=I$z?%l>I>ZY?8C^?Fr&rgJ)^X7p2eIc;tS0w!|xZ zw!}L~$0X#v4);{%Y>7~irJIe7#{VDsM|P8SbWQmaWD2o65~{A zAJ-sH`tOFRTtt7_3qR#&NOQl=e5KBOjm|u%GY{#^OLgY3&K%R3SLn>Ebmkj$=9_fp zTXg2zbmlvB=DT#}yLIM!bmseX=KFQ#SDQ0=e_dyOLuWqGospTTGoP+ApQSUOt26&e z7f!j(eu~a~q|Uram$nCW=7)6VTU?p++@>?%p)=p5Ge4&D`-IMXv@YJ`bmrgd()^Gv zoJV!$$8_c=bms5s%unmg&+5!C=*&OUnP1eIU)Gsl)tO({ncqm8@ePd5T%JzNO*&nAfpQtm>)S2h#%r!c5gU;NnOGBH^JVzIAjn3SlGdJtZZ94O%I`e#; z`4XM^Wu3WK7jM7Le5KBOjm|u%GY{#^uj|5mLudYr&fKrd`#U=Oa$Pu6bmpUV=Hqnc z6Lsd9I`io|^I1CcxjOR&I`dMUIjl3sbmkR0^D3SB2A%mPoq3MVT%$8L=*(B@^l**N zJg74d>C8)Y=CIBj)0tQ3%&T=VV z%n$0!59!R0>dcSn%undd-_@C))R~{wnV;2}U(lIy&-RFq0|&ac6&z`g?Jx^KYD^2dYkyGc==IF)SR59Y_&ycgzg zviUWbA7S$zm>*{IOE5pm=G`!Vi_N=W{sx<$gZayBegBGP-BsfNW5JQMO7@ySIR3DEUSaw!AXYrG$E-pOQ!$DZnMT~ zyA}`T8pQP_x1!+eM!qH~Wd9l1?H@jPkoZ34Z!+Htu3&&fSK!0YC1kKBkgrL?8jIYFnak97?nKv=VSX5l@NI0ze6TwJW z;-yoe8yG|2l&3Lp(i_(13B3`=zre?V-v&T&(`M+H8&lSUqfAZdZj1RwRktsiNXAs9 zB{-^vk>fsuhyQf>l3f!>YI6Mjgz3^sjM6~YamWOo?P)Sm z@aJ0;1?SK6CH4J%*tLHvQ!dY}!Mz2$eUx!=$#VS)?0$|!5T6Zs;v>x0==@iqyv*fL z%^}TVZYs}Upg9%{4690CBwkE$#B4@mHKJOPqadM-40E!gPHVu6zs9KDqU|k zLqBD(YthM$ukwLGl)Gt#mI& z823UB?~D}t!h;7nWRXz&+XoLWf(gkxI34EDqJsy=Mf|c&=!M%ZxIIg5cO5*KfEyJ* zg+czd!tDvT)vY^ta0}cf!H(|n9<)dI!fh|yX2ESIhe__UL1%%eK2klydo6{UCb|#S z1P&ggcKapp>ELzh!GmN^7unNA_H-RCd&YO0h#1(@}t=3(~_35UGcBL2*&Oh-QCE>Y@>x^4tPW z_SdofUbgSKXW}}dJl_s~6yI#5iTKw0jCrWqB=N%!$~w}B^8Co(4<77$2nUp-5>Pp7 zWxeGe2M?}>TRYsgbNfK~qc)E?U6el}!W948UzOq4ZIQg^vV#XnR{?}I2fEt%I>OY2 zbq4&L0HV52;ZS|1x--#wKyeRXoIrW5LIozi?J>#sZe#oHlF!&7dG%A0Lp0rnkUzRK z-V1XRbaWl`u;w=h4`TjBT$J7(2A^=?#46jwjOvZ3bCy*mK&=HF-BG?`qy8S-U#iFr^dzqv+HJi`MkmGY0kq?CVD{QkT_ zQBt0kl*f~bE-1eOb_+Vx;bE^*UN2lc-c&TPK=B9;U&lyW2rFejMBs2ij{ZxSf{1`a z@(jcFS7e(i+YaVUm5=IUD*G5L7x)u?2$r7$7sV54Sm^1D_?NlK+_=!wHxg5Q0XK!@ z8O(%?|pW^>}1z!KI%X52e0#nVLdQ>R0$eNi`wDG}$3< zwIU4ipp^f-4&jx53N}Dx=Rku(cV(xrNybX5UpB*&oLY`Hyp&m9h`BNv8f+@T-*nlyaI%lz%{%!T`eXrOIniFbkCO3rWHQ zwH%zf`&C4$l>I}*eLUHhskn`0B~i+*V6);GQXn0BlcWz$y+iQwpVX3kJ!Gi*C?hZ+ z0k~)cgvma}lYqG!QSOIbH4gZr>u#9w9k;k{gLxL4Z-DuHHpg@_BuMguu#>*3(rr{9 z?2cnUm%w}ko10*+fVqYJlp!rsrDx?t*!=-FF|~yk!2EkQlb<))TnF=S=|;yj2j2}3MnA$12&VuY}$Wor4gZ%Yu4#WOB4zG=6D2?Q& zT<4$ga+V|WGM!n~nY}QdrArUBk#uc^T^HOmOm+=OryidROzG1uJMdX1nHeTrX~GBI zGKRU=gm;_pL%{ktD-d4T6z0>w)V^!i4&Y}&=CJOEoj$C4fu8{B^LPWWRwfkQEx-v- zC3JrrNH1#xrZKQ~9SKZhz9mTW)u5wc?gQ@ub%Mx$K*d8u#IIcH>0T(Crp3X;NQ*Dw zn;(hNz&Ac$rN^lGN=yx@H0sSsFB-HT(UuduO|qxSHq*j;W!xNNm)Xn6LCL zn%%3c6b)cpnd2WD^9Jzj6-)XPN-*9Ni;Sg|zOx|`ONNcXabTX2$jZ1o8H-cAzCb{> zgx$$RG?|#6NJZ88ao=b(q_+FQfsh(olvIoK2k$QDQ0B^)WPI%iyHUGf(icbp>F0_O%hQ!e?PZCfeyR&OT9F?b#1Pw0c2Z8|^xkubk z&B>u56n|u>k8Uz0o1$r#ak5#t6K9T z_((9|9ZV(?k+406DVQJ%z%H>ABlm5ni$=u6m;IuPaNh;&7hm4e(Q*k; z9Mw}zTom;sM(h{&xcb^Jp@BQDWO!K^Rnqgoz+-d0BvRvw*w3-W5I;lNjEX2P!8D@ zYko%^yFx)jYhG#WWMv|Zj9FoXM+e&^Qq0}X;Oxq&(Y7OiB@vf`I;be+3@ncbXibWPe*R_6qxBWOJ0XhcIkH1{k7{~pcCE{7EMA)1xLZ_JLT70r=C zv405mbL_lKAGFwJe5j;^igXy~(g|1zHB`TQnERp$~r|WT!!o zT}2C<_Q+z~9&wv$y15`@QQq^~)3d2E5SfOa2>KQ!ieL1{%aT!7BB72(6Ue)?lk?1+ zo;ZdNc(Trp&*+|=WZ9Nu%n`QIhN4LKWYpSKUtef`HdFdA$xbT9q?IV4+{9Q$O8Im5jt0DBbimn_&zeX8=ren^n5i|>PZattekk%;3QIWFHBL|p8 zx6@jCP92(PjL~=HZ`qsC1IiviIhz}It+8*CPV>?IHPzOT6pXM`nq;pb5uB7}_Q`=E zAcc+OxnJuw7O~hh*LoLvu0(VDzUz%1o6gdnf~e2cJ?XKScpj(Ml{DsP;c_CbJ;&m? zw*obkrcx@-0AUWLf}JalDT5sd=aniLUI=P>Tn0i|f6?=Em9i9vEr_>|QxHf+6(X^f zE9Fh)%2cdvE!$SEOpZ=cCT+%C>z>KV8uTv@8<-}A| zty0l9SE<_Gs2p|mJY~{r^Of@P_bNwkd%tqb{${13qD3j&0HsxTC>5bjMcLk^*g^|2 zU*DsYZ|zYgH})#Zl3v8whxGI*llJ3eiv#_N(zsYTVy9P`+_VJq+e?&+N>!OMT~#LE zt}0XOFz>c^NGW%q<7*#QCM|(NtAk4A2;L6vUZ#|_kHXKWQobF07x;KsDZ4nLRP0}_ zRL)8$NA#^!DmLGWIs03cW4GO=9Jl%-%JBz2fi&H%On=~B=;O1>q^6`xni zIzO+JKL8$mK&iO;KM>y+6a{Lmn2xFKCQuZ#0kj)rD^n)*f!2bygU&ccsjw;XDqGd7 zl#QKUW~(|wnFT9q>&Cp8@WzD&e)8(8I?Z-p*=Nc>K51RW-6&*S^p1(@UCW4+#diks z8qmv5{^XSPmBOCnY2Yy)l=8@uybNKJ{M1j8k2=r;AX`Yv(>pnFEis1=`FoD|^pW?< zxis?k5@>o<`fJoCf(rPf^i4+|_FOOhZ5EN?Co4ZPeOCHwggEJMH{N~FwOzX@13A+? zx&5fg9hhbl`s@S0`Z4KuC(Po?ieKuJ^0^wcpXC$ULr8&q%0NrMB@L7`P|`q210@ZV zG*Hq&NdqMflr&J%KuH574U{xc(m+WAB@L7`P|`q210@ZVG*Hq&Ndx~|HL&3+O1Vwh z&3x}O62JDe7X$;|NzS>RKc(>rr=KaDvKofR1G$OX4EKa=@c%+F?C&HNnZ=Q2N!`7Gvg z{hDq30>*M}nr(bG;+FWJViCps=0f0$|;Z({6X-pt(1yoGry^ET!Sn71?UV16m{%b0gE z?_!SE?YL~?-OPKKFJg{Y@VIQ_9_I9Jll z=eOz{z<+{5Qm zAWifmFH?BySgxl)cn`2#Ujey?g`7i+P0>uDHnOrQ*D*i)KPB!H4XI5TU|toF_FEWN zTqW@g=2tU+K=3B<{V++)pdVaQl{3rV0^h62pG3AOjjM^<=|@~m*i4mhS-Hg8IA)fR z!=VGruVcQId9`TZZHj|=g84SVd(tu3Ww1!kq;jmdkko*oz-ix$V%(t$^Gvmb+dAVjF*w`45=yWG<3{cKvC_KV%*VlA%c;@ncHIUJidv z0l5P#*DgA$+~L`OLa}JICSzJFIwqU4fw}!fYq_c$$=;^enMZ}3P1zz8U{kg+*KjVr zo#plj-a|D=TpFLsSYsQHEQ8UWUMRUyC=eJIm22Y-WNM-xd71Rsk|Ws zsNuUM{ywmAH+i#T({=i42InfX`?ehJ8`%9xlRJIhL!U@(WcMHEaNol2yG`yCKYhow zmEB*>;l7>S|J&f+1E2Dnr*Uw~M+ZoMNqsr=R>IugMBf25W!aN0UGn>%CM8!n>=WO= z*@Z0qjJe3COi$WUXE26qSL|?`0*dnYNCUqhVVhzumoav~CWretyAS1XU&HQU3wP4{ zT6RyG+$lAb=Jo7;qrtsL)En}DBgnFz3i-8gE$$kJ7?mH9{#q--iR!KLnl(M^zvm(k z`}WYUU4&2dH6%VTvnhWTAFA1u$A3=lHf4tR0L`ZCWqu~}pEG}f<W%^MmZ(#r60RmfOhnbq2e?$nG;W|BN@V+|6wNI?Ml>?Q1x^>Fj^RA`;W2Q=b1mv`MQDKH?jR6I2~GhqqQ?y`y$(w zCS^5>y%HtdlP+;-ko}BJnYDwkO?giAGd5+9;7wA<;VRn;JaRt zJl*%Ye_BBGu8BN}i_$^khFP$tZ|CK{L*p>Xi_%J$_FX;QQ*<>-mJX?%aeIIa_zszQ zl=&9s)nbDdo3f7idg0GDUM#O1lHqR|mb^u5(T zQkqCl_O#!MHZs~2F&uzi$M`#BF-)=0a}AdpwGVXBc!l&l#(I|fJ*_1taoL#LnLC)* zF>hqv#9Y(&`Z;7_Q}#3OdymALKK@yJ2gkEfB-Ezt6ba0fL-jnFj*S;f9=JsEweOdF zSCiz{>9gNRuK$c{qV-t}OHjTVH`kLjqMYsNq;AcmAG??Z~ z*wmB5-)`ZayS{F0rugg}3i`$C$e5|Gv-tb>T=n%&!he(K&!kUTU&jGvfOhEiV<7hf z-0n1TJaXTGCFE4p_3b?*W~U!<4eTIh7iRit7kbLz0ihRrx~JbD@HmMc(p5;GO=4VO zPls*)3>h=}9OL~4a_RFp(Z6T(Ni~7)e;bO4^eOjsTLP23Y|QPn*qLh}6aG>wy#)m2EJ141A6wAae5WX$Mu=Rzn3 zIF~-3HtBOZq)DG^Kr=vc-@MHIrs;Fd!*E2nJDBf!gmCF9X`rNmk_Ji|C~2UifszJF z8YpR?q=AwKN*X9>prnD421*(zX`rNmk_Ji|C~2UifszJF8YpR?q=AwKN*X9>prnD4 z21*(zX`rNmk_Ji|C~2UifszJF8YpR?q=AwKN*X9>prnD41}3HfJH$soyYZ3yOW^b+ zgmz8V4B+&ggZ703nUyB`vOFVArnG&N-hPHSr?(8>V3px}kg_L1`#~3%DdkH+>p{Mm5SA%y`b4sl#2DB%Bf1l22i*aB2lS|2nRe-!%CrDz zD`+q1)U%XnSAtf6)`DIIy#s1E8{vcQ2WwnH=swUc zQ02MGv?kC}(0!mCpw~f1pQlW#16>2U2lN8y^jXTZHK0d9uYu;DuS^>NtpKeDJq+3f zD!%}3pjDuUL2rTV7a|@|=tAYFJ3zk&4bE0<-<_=-+kBC7>}t@HpmXOa$F2kI0@>fA z9NP(Ma40A30L^wPC#`WRGj}p(j|<+aMRe$d^Zw?IeMK{uc_(3PND zKu>~>tXGbj3A(agIbs9o$$Dkleo#|`GHrE(a>Tj@Me%kwU+VU{J6#@+x7zD*yCdO* z8cwtYLkTsO<=UGJhlAl^S2P+5`hAIDB%CGU8S%wb-(X1Pkg^35a39TK4NpVC;bhD@ z?8U)wAhJ^Nx)(GD6QjPUh+-_^Mt})5hjquOZ&52>R9D16#j^KN4P{ z#uBZuSR~dLS?Cdllqce7b-3dWx5FE4%k7KB`y$d;9G<*U_;ownp5ZKiwU_sH_B-o` zf*}FuP;2D ziN>8XjrHDMmF^HUR^oQ(cGkz#Xb88rCpj3SK)iJ;p}cxO?wCZ%6Ha-83%s6wQQNch z!D-J5w80mRs^NgsfeJwFV!V4OcPz@Xyl9t_NT9bF`C5jiI*PuMK}-#)zBoB9^q{o;F*Tuj z5lgQN5i6cM}5f2XdGoQ zs*d_83*e)X6_A(8i3f*qlPlghGO5CUlA2lQa%C(i20v{WoOMPbQK+rUM_F-eHW(ow zO{VheN+xKOMlDR18;67-`g?+7YA9VE&Cn%AJ~S|~aHM;vM~(H0A)3i&dSqp?*9KU6 zYpOW8W4b&{#cXho3@-J1#Yl&`%PS*cC{5TwpB^;dM&wCG)mRPNMWP9BTn!B|OxG9T z9koShQU_c~jHP6y3iPUpWGvk83sLPu(}?yh6j`ap{LoUsfiY@Nx(P@`M6YU0M$ zS{>1T^rzx=qK8-@M?M};-H0zfQWp#-BB^*oJIrdVe--yR9#6WZ_IN}~?eQ#>v3uI% z+hxgX7#dd5 zulA(ImIu7vu;0_y3qH3sq>iFrT$BQExqB0}s%<&~;gfcv4pZa5ZMnL=MJ@)md2c!iQ=4eUpEGpr^!P7jn#L!%TW zHQ0tWkuMdXUdlcVVO!ty$NbusF?|*Qe)ob-c^aXv!PENOLQj_XthvWc!KNo#vI-d z7gM9x9q~teqO2C9Da0Mt-w1tlgahiBHcpSDvSmgE+H;F|RB4RFg2VJsb>4x-%4Mz!;Oq{O|kOc05zg3O9zl1@Yhk}X%!qj`{WI>ksq4WbXXh=JXf=jc$h?9fD|Vd+3SG(Btx*Qd0~48S0a!9WBU)7Iny%43X|pN6=oVv1lYD z>RP|Nm22{eSdvX3y++5cBdxPGa3oGM^yBK-g)X+l!hVUVb7rHUiG?K z(13UsI2-6`)fWrKBVpV*!wS>b=6--_hOa z>TC6Q+g+Y^S952px3_iCoG0}sOg%R4q>i05E(@yFNZ(+u;+P`xIa6>LlMyw%Emcf z57pP!^mDN=k_EsZp0oHhjq?)$hoQCb_2`GxfU8+PFpI~$bnD-U)Q@7cfyXXSQr>c! zw|U+no=orrInxbX2wDV}8`9jsP$pVK1yh}h!h<)yGB^Z%NAWyOV_&YN1_#|8j!F|! z6EsOAJ))uoLcu|_d^9XVLq~%>>Jn5m6nKgegHp^oiCAQZF8idk6-7$3FtG>*NK$N2 zMZ@93h>XT0Y$cw9q}N{ASz9~F?p=D~xe|BC7ms&@F$%;}uI7u~jEM;#WTd=HO^ieW zVw{~8PlbJ>LBHD<$2d)fj<(R4MwO-#Oo?MVeX6K>lO&HaWMHst!zcs;!oE~gMFM;Q zF^o&QBB5@P5eql+(wyqT6LJW*jBix!2vggG)*WM_bO^bnI>K6@7)U2ny(cssVH86j z4J@^=f+$!q;x;NT7?=HAD3zX42zbMh@WNy$M3amw&6hrmYJSD@+!jY9>%PM1VTdew)nJQqGJJn^&fXQ=b#1}v#9?8VL zGCN4J(uYFW!ecNL>Knm$i5f*`of}mmGd(yD)hV5(kX()gF!fs&NVm`M8ogq0kJt0>DLo+&dqw} zb*E)p>wWQ5*k6y)b23`HJQA-TA_dJ=$5ekZq0&sH&*>d>dfRBu5hHa+i%0?)%u2<0 zx>@$$g(7Vj4n+oiA&Sp6s0Ha*peaimEY;NDULT6YMyXNh2%x>E9LM4fm-mqk<{N`) zORXO+WPF}> zc5U}8>KmjidfJWmwD^VS9j!@#W!dvL! zKak zjclphH}&e(Q@2gpT)v}xbH(Peb(7YX)m=Q>RyoqRxqQQ8N)zaQ%vsYK|oKk6TYVYaWT-Ic(Xq-O1Y5J1sS2tD8 z-ch!#%wGBMr0Pl}wCq4xPi5oUW5;ZjTaKt2v(KLGNK~KNUTIrjUR7bAWwT$rqhflK zOmxM*a$9xF^p;soBYj&ZKU}eS%DT#RM{YT~diM6oJInS@Z>gTWzRFhF(o@l7uUcES zrL3~jR%vrotu24J!gg`{K<9Ybn`NuZ+k23>9z~gax}uzpr35`EK-Ez3IVRi!d^8;T z*!{EYPWyq9{0FX+&ywMdohxxWF!|rj zcr)Xd7$0}Gl;3%tly6}?0~>$BoAN=%)#pomJ1~V8Vmu8Sg;M%AULf6PFh0QeOvaTL zO7|wlc%3f7k1?Lj_)cI7zlHIiP56p)jP8#!9$@*?kS|f5j4x$uzevUx0jBV)uwOCd z@AHgpjDN~_I%6y&5b`q^_cA_}@#lakzFCZ)W<0?7?~Io)J_~v!c@6h6zMb7aWx~&! z@NDRv{L{Y6l>VCq9#>vt{5-qwXM6$5fy$=^4HL<)5E%Ch;~xW)K2|e64SLh#4+2ws zYuNoiO}KWpbf<=o!vClVzrpxncE1$mK;dclD~z|X`#X$je`gA>7Wp9gCM-N6Ji?gv zeJ1>AU`p@qI*I?2-Dy8)axX_XGIVkfzeE1MY~TO-|@TBZAXVDkSQ-_tJfDC2z{5`T~J1Dz6oxn9cK7D{|CmXe74 zF@BM8U5|91GFQ4k$M_G7t1g%BbST) z9VUGGrAGHL6aJYAFTTtu|9xPpe^milUtVK(8{=cpPEq`H&I6dz$C%E0Ap9xDbnXM; zml@Of4}_<8as6g|6)=TI=R%PCo$TJocsIM#c@gA(G}=Lu-^F;T3GX!Fv%01HODumg zV>(BI!h3}AGmL-Dc+IeMKLYJ4#kY~Mn=zd~LH;)alfKplB|dkNl&5nk$h`+x^jFIy zzFoMFD+d^VgWY>V(*0@1YZ%|sOW}SWy|%_{UXNO7%u=OeQjr)V!Vg( zmxVmeWVu25e~Ix7#+B$d$$x_JW()^RDno86yb{1?VmH%j+u=vT?Vjq#blBX zd=OZq|3gx~=_-k<8Q;iwgz+)=?C9eN~ z^iSs&kozXabbbNhcNo(-281uaM#|H9283T^{Pw*Pzr~o&b0GJbUXmYIIvF3iMB=Iq z()~}2cQM}YlkV+bmhQGesgIW!e~j_|uSxg&8CN_c@m64puZr;-jC&ZLY&jq%HjH!!AloyxQ4VJSbK@c`oyU}_JRF#ZJNDC6%k z9%KAh#&pgC>GOP5`rpd2HMb z-HdN%{1E%!!uUt*t~@UN|Bmn^+>bjXwxi!5Oy{3a{yQ1d`6q<0V@&795Pk^w^a`|h zjDNv+E92iV-p=?4^h2jtz~htB|3!?O7!NaE%lIC~bWSA2_ZQ%!;eP|;Md-H(Z)Cig z@fOBMqTeESI_Hu6hZ%2Yyd9X*znk&1jQ23!&3G^4vQa6&kMU8A4=|p|SowjBuZD3Y z<95b2#zDq27~jr#gz={tM;U*E@dm~(G2Y0yEG+fAg>enz-HiQ=>0CY1$47xlKXi^B z;Wrr5xp{;;BGR4C$s_y#FzIUv<6kfyVf<%-<@t0Ze@;}&Kf~_tXMBM1a>mt9$?!kI zc!cq{2u~ST#uz^@Fy<#1|CaFv##5J*yrOJld>&&u-%8|%an;kDAI8%eA7p$g<11q_ zyw!~FWPCg0CmF9}{71%{8Mnu!|A!ghz<3+uZ!@NI&M1AqVm#xA5?_#z{_TvHGoHnG zD`N-aeT*9!&q_-FO^lZ?9%FnL<8j7+W4wm(IV+_94UE?_-pKeV#&jMNrT@>24={eu zO6gyDM!Mh6xRUYD7}NPf>6O60Jr2o~7*D}7H@zj)bU&r_ZjPGUqDC1`s|B5l4 z&qMJ|Stb3S`Xhx^$;{2b%E82^s(1B_4lpp1VD*cS;u%L0a!{4vG@j5je}!uUIk!;E(` zzK-$Kn`HR6FrLqN9pgU6_b|Sn@q>)N%J?D1zhu0P@i8Bg;Xldva>max{wU*JjDO1b zb;fTo-p6?AY8l>s#tRw0&G>r870*fiKLLDnCFTJb|BP`p z%kZvb9Af+t#_Je=gYg55f6n+d#uc~7@T*^t;hjhLD4cKkONmgz-+szh(R!WBZ3?c)J<*G2X-YR>pf7Z)3cV@oyO)V0`jzQhzflrT#8t z+{Czp@fOCzz@*>pj90V!bBrHgJnIM<-VT;;Wc(|3A2?FF|C#YP<0C&J)3cHBdl@U! zq z@fU%QK1IO`80o&7F`a8(25aS`(%pe`C<&j(n9eCDd^uw}r;_j*#&o_c;cqggb7l$u ziZPuhN%-_TWcd3ScQdB*Ey+E}n9i*v{CUQ7UL)aeGp6$z3IB#Mov%pv#E;4F=^SUm z7c;iuyg$M|#&mul;g2(>^OFg0W4x2`YlKh3Ip8?wmfVk8D{*7H#7^MT;oig8XTqxq zpN{gt`L5*uD~v}Nf0J>P@h=3%xvq@=0etl7cwS%|AJixf>F3vCG0fF&6!T1)&V~n3=Oy|*) zex`n0=9kW&Cp^NK&YLIvJ;v)9pSMoxgU%@?_p1aRS9UVKj@{|JQgYu2O#19$yo=pO z7$0Q+&oMsb6EZ)$8DGG7591ETdl_HFn9h->_*O9P!TFvde#Qfgf5dnRMbiB>&7HCG zDH)!n8k$2_HFCgr5%2VZv<^Pt)RCXu|yxLo4#S)8zhj6aI+_|JH>6Xu^Lt;bSXB zdeiASS>km3-6s5Lll<3A_*D~DjxdIQoC%+7!qpO|eeJ z+!Cktx5DJU!GyP(@CzpVM-x5;HANp^tqFITaL9yLneeS9j4g@`;Xh%*FPiY5P1tsv z(f=7HJjaADHsK)?9yQ^l#AsFIb*l;AEirl_d40u%pEBXsB}T6!FKo9&`A_3&6K;|? z9sVK{_DPIhL0%t}7_F4N?ls|Wn(*@`yw`-MOc&`(hj*^T>H6j}VZRBFNu1W-I*HK= z$?MA|_wSkf?>6B-NQ_=dUdNmu(gzjF>mn28b-8Kx#28*j%Vm#r>%O`NAKl_-(>GJ!e z34g|fx0vvD6aIk-KWoCfO!yZPr`v<67}=qIrPFha37>4jXPWQ@5~u6SB_`Zy!b?mz zV#3#(@NFjipv39)K5W9H7O~6aJkEzhlBDW2CFU|7uOR+k^uqe1i$!X~OrL z@S`UDlnK9V!f%`K4Aew@dM`2IxC!58!k;tYXH57PCj5p8|J{U-z@4B^&&ek2FyT%U z4w>+WO?a~jf7^tAY{I`b;lD_nu21in@CE1@F9cy*M&%;V9MF3}4v-U61F8kpf$BjG zpt+z%&^*w5(0f4_gWd21P*hY`Yv310_K#Kr2CGpcH5o=vvTqpzA^7pqoLrfYyLM3c3UI zG00Q4WAFMz%X+6ej*XcOqmpv@q9#(og=9ng0{ z-v_+}`Y+JSpkIPs0lf!9C&_JV#3dILny=5K=j8}xh7A3*y+e+2yr^gp2e zpg)8D0{ScH0O)U^w?Kafy$$*Y=pE2O5RH_|K;@uGpbF4r&=k;A&=H^`LDN80prb&v ze&iUC4RkE%IMDGRnkzT~bRy^^5Uz1EZAF~l1U&}&7HAvjagbiGnlAPF{WWC%8Flmh z2v=X<{J<(`2$TR(z4$6<3+QX0M?jB)sGPq6(#rIEFz*0S***!B9|Elg z-3+<~bSubwZ`}#IyFecYtpn-rwe`TC0^I}pH0WN?PS8^zx^E759j`=PJ(POxLRy+Z zZqO*`QqX0fPEZ$UA*dVF1Jc^7R+!sB3qb9l4iH^-eDRq586h^bp$(vnyBL=Ci_|_y z*YAdA)=Fl}mtuFW>?-XSS=ujB+k~>TUnDlak~>Pu-Dh%tuwC$j?*Gr*R^(1Oe`;PG zFSPBz|Bj6f(wj*9U*2Cs+kilR%m427Ic+br+kvOO(0&Q?47;dkyC`Js#$df`gSMvw z_9M*f2a{_X)66cZ>HP}zzKY$))Ox>Fq~PCVM?}3UvbRpvHh$GMPSi{1Qe1k^R@U2L z-TxI^Bw}CKyn8nm+MMOzc}JLcyCu;7$$cvi!*;;>nlCpWEp2~k*fzAZ{iW*ur)_^J zyRY0k4O(ncBsY?s@U}*XqqL#s#5dG5bbI->3D4}4S;VGQyw_%~U9ocSqg7zPtkU+M z*kt!#y8UN$TyBDv-dTJ>j=jbY{{b!LGgc1Q`74Ewe#yHh{r{2;(oI{S9sX_o^X(>Y zxs}>NPkNJiQ{wWT5MYqa+7HpR*W3S+(~Jyx%DoAleyh6l)^gdq&RGWVuR9vSV*fq4 zFYBRi8r;_1>u&XScDq_|LY3I#)zzPQu1)VPoGnJXjk=P7AWmN3yS*aGBtko&=8@6%JIy0fRFXToFQbiUbLO4)@udSUT zaGne@k)y$iI7KA#Z*WM4^+_hO{)tWZwZ4L_O51&7D^l9xzO>yp--o>WcVNWYOaCeJ9;Fe-M~Z$P4aqj7PLACC8G!5K2C=mO8MwoQ0e zMDn?_du37_8ewvF*0dosI^_jt_^58DqXj#Whv~S*cz^v$I`uI}+6sGY7!jvPx>|9P zgE(1HYz}UUzs8}DzYUGwAda1K*WuidsCKrVQ7QAG0C#&*Ef&{)Xlx9bwhqFetd!Ggv=F$66-FJng-!LTxyTv~O4*Uy6FM=`||s<=#G* zb)Xr~&;m}vlS(cq>~iVu8842F;v+y(+I>O-d_yeoQ>ac(LMpnr+g~UxhZ20swIG#B zxGr7rPt1Zoj#^}>59i8iBQ{aTvDG}zsKEIa;^a5lZJtg?)E$1&kMk?U+rGJh^m&Dy zI8ZI-3qf^s6cHVOln98UH&G<1XkWrHT#tG&EbE1^ODyijDX~_~aHc#DRfjHDz9vWN zm&;O1izrVjSg~ijsd}gD!QyUlrfhayF?(pyD$e3=4C1qLsSD7`S${jF4pj+SMXD*{ z#(B4KXm^H54Tm)!R9P)ldH7mpZkSDZNEcPsK~xrrNeknIE*zaC&WFrh_;E*z!{v&* zT6zowRJeUEO0AO>VP!gV^(9){)7q8LhKMr^s{jYB0zO(cxd<8t+FNLzF$Xi@5>R%~u0 zEDkopFi!Myx&7&rJC9Bn6T?SpO8jk({vtZNe{Rs2A+fkC(nXiuyi)t5wWuBH@UWMD za3uFIH^`=Y)=*rBnt#l59JWMSHS&igkzE#vG}W5Im9J|K4g|zGlXDzap&gF8RD2AL z{!kF7-LwTTwyte(*4EXw)Hqt2@i^t2+g9J&*w*55&8=?+H8ys3YUAo$_m4j9jpFFY z*x?CT*KRb@!EAK?Dm@LKkhtTy-st^^K0!*0!4Z+UAy;=Gt1f(}f2=I#`;d z-NmGNo;azN123$O!hsvxC7j(u$5JBUEv>HRnmV_mws~${y~8!n)z;E7*U{{*cRL#D zosJqxc%z37WQ9DALnRr7EfeLy_di{q)}JOWP#ScE4Y1`7s?TRj}0JW`W?gV4RW^6ajC=IH{|q(EoUnJ6-lF0#F4g?OqxUL zHculngcmFMbvV>Q5*QhcV0e^j9`W||;uv*0lvo__jsvr3o>Q~Jsk(F|XxbJtQqH;F zh2j{=%$!vJTrVaCa29WkFHyTpP02F|b4*U*Y~qhbWWoSQnkREKbm-6Pf*dg0{1UPGaUmspE9)v^o0h zR>E2D?Ny@~$)O1ohaBVN#oRY$D)Db{W2P&=W28g7F)tH0W;lIk>Y5w#heuiYs|kkd zagcT>HRcIJX$w$l)D9)k5dE9wcj;wXe!FCT(;3Ej>z%$;IBYiRp{F*SsN3I&(>r56 z%ntY0g>mNWay<8-igCv#t9XKMx9na?r z=`n~9NGw26IqT85|&5BIkQO9@BiU zp%g{Gm)%ySn?CUl$WT+Y+39F3vJ_J=-oeP28t_J_mng|7OoWS;Gz*W>H@m=2vt2 zeCHYy6fx)8C?1Agp-@qMea#U1PtTw>ndkNN&$F7=HQbYj7_LPb|2Cxt>^X1vBe( zQZ<^$^p(~qTTNKe1B%6v!*D;m+v(wg3V*i|6fA;w8-Zn^9JU#H%j6%n(2GdBa~>^Z z@&redA$oXS3=mm~Myl|;2B*Ux8I6Y21lE?N^iMDJ4nl_G*1r<;d#$?*V{VF^A$u&iZ=A>1NL=aABCRW7=hRW%Np8t(`Pg2NH9AWqD9Lm7qNT-DKgt_F;d zP$OB@*grG@;V#p~4BsQ5uIU zY^xGETwxa|6=yw_N|(<+l4*bo>q8Vs-C%^?9mo>M6h-zpu3))f@{DSQOB8TX1yYNN z(sYzA;}T2jJ5sMttf-{;dQhv1iY*Czm6p>5 zCSB)U>|<=+3$LHBsx_wg3I}3UgcA;=h+1Z`i0H7Sf7o);%aj9)$PY`t)Hcd`L6<)4che7Ahi+2#A&no33VAMGOQ0p7_3woTb6AY)yfmr~w@N=b*S#3; zI&p()iz%$W1~6sTAaA+&!Wh2w%$rfh@Pz}fNht0UhkAS~fNpIliZ%ASp)B5A=ti^D zW9mMkFbrs|8&qTR4nrgn!QjH5TMIqr zy_rBRym5!eLD^2oeDfr0E^)I;hP}8Evb7@BGJzNT<`+)J>g05aN-LLm6?JDftVHvO zN4l(Pu6sL=!)bh9m;4Wd!Z zj-w`s?s+(s$}GC~)Og5BzYM>CHV31n2TruOC* zD6m|cUQQqznGZg5j9qhO#Iwld@|d^FwE8?JX8`R8pxqe1<@Y`L1IVR=LkKF*E~5o9 zW_Z4^dLGf`!60kZd+B7I4tzq5Oq4QBa!bA5GtJo=o<>}5$AWtK zK{PF^)(z9}>7ed|8^7g1$sChsWiYDN(=9XLXm?&wn}`fJI-FNH9f2g)u3^r&E9+Uv zQ}-?+6>GfvupAEWpY)GdSn!$oerSRj!9sq_!^#=5!x^k73K?vF(1$l7{sg@lFFeJ2 z7!zSR4AB)L5B{=R@a&~pMXU*wt1X?i*=quIG5#ywMWlG0V0!WxZzyPao7M7CUhm-S z2#11U)e{P0>E#6Sn!Tb=pVxo>3~W|b3*<9vxo{DA)#iTXiqE>NHD$USzx#I@BQ2fo^WtQvuT^(Gyo?u#z7P{)zfMQ__F9OXqHp<_ViaYo4H*Ngcj+ki48iQGnT_~M- zwjFo5(Y$)pgex?x4#s>g7p;K80(xI6kEaFNp2YE@|tB5yJ!LTpB|L z6DiNo3d`AgOJUp#L-YZgf5>I|c}ZSRR%0CFM6Q;mbTk&XlF>(loHuqlqrovXBJjkMPk}8ql8rh zVg{)lb9|PgT5HMt!&hrRg~q8_z7`=(mP7T!yv+<1j7}HS4^XKWh~?dFI+G*LVi%;F zy=QMnk}aWaPGZ(`5qGK-F?-B?G6q#9*_N99c&MUrJ8O2MwfejXjoHc7Odc19=%L@+ zvCz}!T8OMPJc->ktGzwF-F@Be?#}E$GJ9zjmhJB8^G;MA z`A%RCeN@iy$_kcqcWU}V6WUnNg|FSv;Rztkac849^C_FQd{?YuW@(cq((jPvE*o7| z&u$r+JX>2>`ZT-`*MBpNO(dd=R}}od?@%`~C?@PfT=ZI0(1*4>vzVaY#pm%w==$x(1*VKz`B0x1AkZR4+;i<=(}QY(0R@ypck#iVTi8%w{Vgb!_?LrM~~WA z7US#mO=b$ogE5vYTXXdJI(KUy)I&>`Tz!-&a%|gZnTxIgt0gS7)1K8Ensncl!!`5e zl9g{m|7971LE=!mt6_O`lexI`pFA9D(D8p>&|QUt)_3{occMjj%ibuYu&4B9AB9E5 z6vsq&;3yodn4dN6!BNjjs7>vb> zvZ8Wf;jgG@b@=C> z3q^ZqFV_l%ddRQW3J0v)+}X0QjibFHM6>~+Ho2LjoifZ9zKiKi%N!aLJ@E-n8K@`1 zgGBSNm9aKVgJus|!p63) zkhty^F^i_R+Yo7AKuc7-_Vg>^cyG|VsyFEHu8M_P&53Ym+xBLMDd(8%d7esie&i&S z&O#(bPCVJINBm~ttfXoCU+M%s9vu)-Wcx}F>2;zWyqriEpEj4^!Fz?YrDb5b$}=UT zy^@)`WHvrDSmL18^mc-J2fVsUXI0C;&Wa{fbWmiE zD)=hzz?eBNxs)P2X_GF+R~yXA=wha{VJ<~)W15FCB-FgCg3cc&2Z0q?|72;Bbk=qZ zcJ0ewbNH5xK>qtWr&{xS<0?nFn$Qhyoa#2NzqeyOOz!(%zodD>(OQKoHoKH(x8PM< zx;k>s#Gdzb=VYzN(95Eoq5bZSmB6r1r%qZ_fvoCl=)ej}8%xD7AT8uK#}Ll!%^OAa z6085Q2Txin`prkp*8J&$&RXQusxs#3HF8zNJYLXt@t3dWxUq@9NW+Bcre!6;OM#3M zeA-Q!=as%rUL*lc5N_h!bE1mZ7sr;TwB>)m?vI4=N$02!n~s?)icSxPqcr4V0CTLx z`ZoLH+E3GY`4Vk64`s~Z&Wu7^mGQXJI%h4)Zb8lBLhp{hJMXj!q^xTBV%YVEIs6CB`jriRkDz~p~_kc8^_v~A{m7? zTRf~&F`!mi70*Jv*od#BW2v$ajI%z*4X5@Q4kyYJKxX7V0Z+!$-X`Etc34>3?4E!p z;{(P7JZh)8C*Vha7~}-J zQB-32%mn?=`HmCtk~#ix0-hrA2?&c*8nflopAz_E!6~J8GQ|&dcX~`j`TPl+|y|%^G z=dxV6qkAcah&%dSz1AyyjB-T=r>+074$@Fl5P1=)HG7#r1aq9&+%r!G%|RDVjrB+a zk>t`&P7mhgQoXHhcrG(7Vai%4B^P9Q`>yC|%`4sG?dxtmQjlJM>2W2pEI?oFzH23 zT(FtfA8}^0)*o5gORu@qLFcb0wYE?t zZ*6p2yrnJ9BfsG+Y9|2w{0toqW4%1cl*2>YpQNau-{l4+A`WraXIipq%MRF3!f3~Y zC#_$@2-DmRWZiz&y^7oNR?Q`A_1Kw1RvWdSU73rYFt6bi^i?;V(3|| zE2c@rsy-S;v}$Nr#V0GEc}R_vVd33kS$a9vo0_soi{)gmVRCTM@ymv{o%6izHaVxE z#bGMR^h2ArB)sb~i(_vQ*yAh?lLQ%@`rzj(!0d}9Ilt=g3%atm|1=tt&u|U}v zPVT-a7>QrRXWE2@zkjtmS$5;D|LOVa%JItYS3maMA8s0Y&xVRKKD%_m_ZR&3Bgqd` zfAq{(FRxhI^rJ`a{$}uVS3iEy)3IRc)GzHgqjAUaUkRPp@%4p2KJE1AF_(PtBcD}1 z`J1ageEk269{bx1_t`>Ues6uxx2``r_S83`OP>AvgQMPenrgrFi(7y5SmUZt_p96P zUvYV(>vivKUwq=xFZ}wr2k&V*?r+yD-@5u#+tI$Rul(T1KhoJ!9EZ-5X$ux#P;m zKk(L_-shkH(#JnA|8n=zduP|onECN1fAC_{vU5*=`T1A>yCSt#Ir08;ZTB@EdD5PD zW!1GAoHsx7>9?rdVH zy?fR@|3t@opR@nw^!#kF$fZp9f64QuPy zY>LP~_A>1N%k_&Fvzh!OSpU~-si|tcwYS@&Q)jt7`?HDV?b9aiJgk!Ox@4kESq1an z*hAro{6A-g%OBemN=QGY^3_7CS6=%xdBsvm3nhi~sobtjXS)~3pO;?$@#f8SjX`{r znomf->9pW_c?Qw_R~B<@Vx3^gt93@lgJH^kmjabv@1z1+8cQy2o74Q0f2z8{g~wYC zKFw@6m*1zPT6MO}QEk4!oS)iF8P>6!e>2t7cehxtIn;RBziZX9O~EW5|L@7&bdf3N z*!347LKAe?Sj@}KxcgX!!Mk>2Qpuxag|*Y`M0UCsDDv*wnYeIu&G(DbYdY8Sb+$f> zHv4qyvy95T2Ti>*j<~G5G?igeSX*=zXUtTswa*0(EilVX)7TpMvodQ@(u+o?pesl2 z?X0r9=Q?AW-Q)-SO~uhmY|>&5^Ip$33O0LXwQ)ilgZ`eF6z^Fl{+iB6xhMWbL+Aaa z2l^}CDaPwpw!C-YTc&m{_0;7Gt?P2W!5UIZ9xolL7&;E+%;4BFXXDq8^Y$z45tCIu zvDfm~tH#h3Z}WC(?cXSCd1C3VO#xf}?k{5Hzs&OT-?wQ2;tQJ3Ut{fYUh*a?Wa{Z} z*Mf?i6$Ji&h*`_9%IF(kP0+SS{x4r_@;)d1QL;f`{=(|3ir#@6`+j}@r2i^^`;mwP z^KS*kE)DX~R_VTZbz0$lD* "ask", Self::Engineer => "engineer", - Self::Debug => "debug", } } @@ -110,20 +108,15 @@ impl RuntimeAgentIdDto { match self { Self::Ask => "Ask", Self::Engineer => "Engineer", - Self::Debug => "Debug", } } pub fn allows_plan_gate(&self) -> bool { - matches!(self, Self::Engineer | Self::Debug) + matches!(self, Self::Engineer) } pub fn allows_verification_gate(&self) -> bool { - matches!(self, Self::Engineer | Self::Debug) - } - - pub fn allows_engineering_tools(&self) -> bool { - matches!(self, Self::Engineer | Self::Debug) + matches!(self, Self::Engineer) } } @@ -137,7 +130,6 @@ pub fn default_runtime_agent_approval_mode( match agent_id { RuntimeAgentIdDto::Ask => RuntimeRunApprovalModeDto::Suggest, RuntimeAgentIdDto::Engineer => RuntimeRunApprovalModeDto::Suggest, - RuntimeAgentIdDto::Debug => RuntimeRunApprovalModeDto::Suggest, } } @@ -147,7 +139,7 @@ pub fn runtime_agent_allows_approval_mode( ) -> bool { match agent_id { RuntimeAgentIdDto::Ask => matches!(approval_mode, RuntimeRunApprovalModeDto::Suggest), - RuntimeAgentIdDto::Engineer | RuntimeAgentIdDto::Debug => true, + RuntimeAgentIdDto::Engineer => true, } } diff --git a/client/src-tauri/src/commands/emulator/automation/metro_detect.rs b/client/src-tauri/src/commands/emulator/automation/metro_detect.rs new file mode 100644 index 00000000..455dcbee --- /dev/null +++ b/client/src-tauri/src/commands/emulator/automation/metro_detect.rs @@ -0,0 +1,56 @@ +//! React Native / Expo project detection. +//! +//! Checks whether the current project is an RN or Expo app and whether +//! Metro is running. Used by the inspector UI to determine whether to +//! show the "Inspect" toggle. + +use std::path::Path; + +/// Check if the given project root is a React Native or Expo project +/// by looking for `react-native` or `expo` in package.json dependencies. +pub fn detect_rn_project(project_root: &Path) -> bool { + let pkg_path = project_root.join("package.json"); + let Ok(contents) = std::fs::read_to_string(&pkg_path) else { + return false; + }; + let Ok(pkg) = serde_json::from_str::(&contents) else { + return false; + }; + + has_dependency(&pkg, "react-native") || has_dependency(&pkg, "expo") +} + +fn has_dependency(pkg: &serde_json::Value, name: &str) -> bool { + for section in &["dependencies", "devDependencies", "peerDependencies"] { + if pkg.get(section).and_then(|v| v.get(name)).is_some() { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn detects_react_native_in_dependencies() { + let pkg = json!({ "dependencies": { "react-native": "0.75.0" } }); + assert!(has_dependency(&pkg, "react-native")); + assert!(!has_dependency(&pkg, "expo")); + } + + #[test] + fn detects_expo_in_dependencies() { + let pkg = json!({ "dependencies": { "expo": "~51.0.0", "react": "18.2.0" } }); + assert!(has_dependency(&pkg, "expo")); + } + + #[test] + fn returns_false_for_non_rn_project() { + let pkg = json!({ "dependencies": { "express": "4.18.0" } }); + assert!(!has_dependency(&pkg, "react-native")); + assert!(!has_dependency(&pkg, "expo")); + } +} diff --git a/client/src-tauri/src/commands/emulator/automation/metro_inspector.rs b/client/src-tauri/src/commands/emulator/automation/metro_inspector.rs new file mode 100644 index 00000000..cee5e5c7 --- /dev/null +++ b/client/src-tauri/src/commands/emulator/automation/metro_inspector.rs @@ -0,0 +1,442 @@ +//! Metro Inspector WebSocket client for React Native / Expo apps. +//! +//! Connects to Metro's built-in inspector proxy (default port 8081, Expo +//! uses 19000-19006) to provide element-at-point inspection, component +//! source mapping, and highlight overlays for RN/Expo apps running in +//! the iOS Simulator. +//! +//! Protocol: HTTP `GET /json/list` → discover debuggable pages, then +//! WebSocket connection using a subset of Chrome DevTools Protocol (CDP). + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use super::Bounds; +use crate::commands::CommandError; + +/// A debuggable page/target exposed by Metro. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectorPage { + pub id: String, + pub title: String, + #[serde(default)] + pub vm: String, + #[serde(default)] + pub description: String, +} + +/// Information about an element at a specific point. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ElementInfo { + pub component_name: Option, + pub native_type: Option, + pub bounds: Bounds, + #[serde(default)] + pub props: Value, + pub source: Option, +} + +/// Source file + line for a React component. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceLocation { + pub file: String, + pub line: u32, + pub column: u32, +} + +/// Metro inspector status. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetroStatus { + pub connected: bool, + pub port: u16, + pub pages: Vec, +} + +/// Live connection to a Metro Inspector WebSocket. +pub struct MetroInspector { + ws: tungstenite::WebSocket, + msg_id: AtomicU32, + port: u16, + #[allow(dead_code)] + page_id: String, +} + +impl MetroInspector { + /// Discover Metro on the given port range and connect to the first + /// available debuggable page. + pub fn connect_auto(port_range: &[u16]) -> Result<(Self, MetroStatus), CommandError> { + let port = discover_metro(port_range).ok_or_else(|| { + CommandError::user_fixable( + "metro_not_found", + format!( + "Metro bundler not found on ports {:?}. \ + Make sure your React Native / Expo app is running.", + port_range + ), + ) + })?; + Self::connect(port) + } + + /// Connect to Metro on a specific port. + pub fn connect(port: u16) -> Result<(Self, MetroStatus), CommandError> { + let pages = fetch_pages(port)?; + if pages.is_empty() { + return Err(CommandError::user_fixable( + "metro_no_pages", + "Metro is running but no debuggable pages found.".to_string(), + )); + } + + // Pick the first page (usually "React Native Experimental (Improved Chrome Reloading)"). + let page = &pages[0]; + let ws_url = format!("ws://127.0.0.1:{}/inspector/device?page={}", port, page.id); + + let stream = TcpStream::connect_timeout( + &format!("127.0.0.1:{port}").parse().unwrap(), + Duration::from_secs(5), + ) + .map_err(|e| { + CommandError::system_fault( + "metro_ws_connect_failed", + format!("WebSocket connect to Metro: {e}"), + ) + })?; + stream.set_read_timeout(Some(Duration::from_secs(10))).ok(); + + let (ws, _response) = + tungstenite::client(&ws_url, stream).map_err(|e| { + CommandError::system_fault( + "metro_ws_handshake_failed", + format!("WebSocket handshake: {e}"), + ) + })?; + + let status = MetroStatus { + connected: true, + port, + pages: pages.clone(), + }; + + Ok(( + Self { + ws, + msg_id: AtomicU32::new(1), + port, + page_id: page.id.clone(), + }, + status, + )) + } + + /// Get the element at a device-pixel coordinate. + pub fn element_at_point(&mut self, x: f32, y: f32) -> Result { + // Inject JS that walks the React fiber tree to find the component + // at the given coordinates. Uses __REACT_DEVTOOLS_GLOBAL_HOOK__ + // which Metro injects when DevTools support is enabled. + let js = format!( + r#"(function() {{ + try {{ + var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || !hook.renderers || hook.renderers.size === 0) {{ + return JSON.stringify({{ error: 'no_devtools_hook' }}); + }} + var renderer = hook.renderers.values().next().value; + if (!renderer || !renderer.findFiberByHostInstance) {{ + return JSON.stringify({{ error: 'no_renderer' }}); + }} + + // Walk all host instances to find one containing the point. + var x = {x}; + var y = {y}; + var best = null; + var bestArea = Infinity; + + function walkFiber(fiber) {{ + if (!fiber) return; + if (fiber.stateNode && fiber.stateNode.measure) {{ + try {{ + fiber.stateNode.measure(function(fx, fy, fw, fh, px, py) {{ + if (px <= x && py <= y && px + fw >= x && py + fh >= y) {{ + var area = fw * fh; + if (area < bestArea) {{ + bestArea = area; + var source = fiber._debugSource || null; + var name = fiber.type; + if (typeof name === 'function') name = name.displayName || name.name || 'Anonymous'; + if (typeof name === 'object' && name !== null) name = name.displayName || 'ForwardRef'; + best = {{ + componentName: typeof name === 'string' ? name : String(name || 'View'), + nativeType: fiber.stateNode.viewConfig ? fiber.stateNode.viewConfig.uiViewClassName : null, + bounds: {{ x: Math.round(px), y: Math.round(py), w: Math.round(fw), h: Math.round(fh) }}, + source: source ? {{ file: source.fileName, line: source.lineNumber, column: source.columnNumber || 0 }} : null + }}; + }} + }} + }}); + }} catch(e) {{}} + }} + walkFiber(fiber.child); + walkFiber(fiber.sibling); + }} + + var roots = hook.getFiberRoots ? hook.getFiberRoots(1) : null; + if (roots && roots.size > 0) {{ + roots.forEach(function(root) {{ + walkFiber(root.current); + }}); + }} + + if (best) {{ + return JSON.stringify(best); + }} + return JSON.stringify({{ error: 'no_element_at_point' }}); + }} catch(e) {{ + return JSON.stringify({{ error: e.message }}); + }} + }})()"#, + ); + + let result = self.evaluate_js(&js)?; + let parsed: Value = serde_json::from_str(&result).map_err(|e| { + CommandError::system_fault( + "metro_parse_error", + format!("failed to parse inspector result: {e}"), + ) + })?; + + if let Some(err) = parsed.get("error").and_then(|v| v.as_str()) { + return Err(CommandError::system_fault( + "metro_element_error", + format!("inspector: {err}"), + )); + } + + Ok(ElementInfo { + component_name: parsed["componentName"].as_str().map(|s| s.to_string()), + native_type: parsed["nativeType"].as_str().map(|s| s.to_string()), + bounds: Bounds { + x: parsed["bounds"]["x"].as_i64().unwrap_or(0) as i32, + y: parsed["bounds"]["y"].as_i64().unwrap_or(0) as i32, + w: parsed["bounds"]["w"].as_i64().unwrap_or(0) as i32, + h: parsed["bounds"]["h"].as_i64().unwrap_or(0) as i32, + }, + props: Value::Object(Default::default()), + source: parsed.get("source").and_then(|s| { + Some(SourceLocation { + file: s["file"].as_str()?.to_string(), + line: s["line"].as_u64()? as u32, + column: s["column"].as_u64().unwrap_or(0) as u32, + }) + }), + }) + } + + /// Get the full React component tree. + pub fn component_tree(&mut self) -> Result { + let js = r#"(function() { + try { + var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook) return JSON.stringify({ error: 'no_devtools_hook' }); + + function fiberToTree(fiber, depth) { + if (!fiber || depth > 50) return null; + var name = fiber.type; + if (typeof name === 'function') name = name.displayName || name.name || 'Anonymous'; + if (typeof name === 'object' && name !== null) name = name.displayName || 'ForwardRef'; + if (typeof name !== 'string') name = String(name || ''); + if (!name || name === 'View' || name === 'RCTView') { + // Skip anonymous native wrappers, recurse children. + var kids = []; + var child = fiber.child; + while (child) { var t = fiberToTree(child, depth); if (t) kids.push(t); child = child.sibling; } + if (kids.length === 1) return kids[0]; + if (kids.length === 0) return null; + return { name: 'Fragment', children: kids }; + } + var node = { name: name }; + var source = fiber._debugSource; + if (source) node.source = { file: source.fileName, line: source.lineNumber }; + var kids = []; + var child = fiber.child; + while (child) { var t = fiberToTree(child, depth + 1); if (t) kids.push(t); child = child.sibling; } + if (kids.length > 0) node.children = kids; + return node; + } + + var roots = hook.getFiberRoots ? hook.getFiberRoots(1) : null; + if (roots && roots.size > 0) { + var root = roots.values().next().value; + return JSON.stringify(fiberToTree(root.current, 0) || { error: 'empty_tree' }); + } + return JSON.stringify({ error: 'no_roots' }); + } catch(e) { + return JSON.stringify({ error: e.message }); + } + })()"#; + + let result = self.evaluate_js(js)?; + serde_json::from_str(&result).map_err(|e| { + CommandError::system_fault( + "metro_tree_parse_error", + format!("failed to parse component tree: {e}"), + ) + }) + } + + /// Port the inspector is connected to. + pub fn port(&self) -> u16 { + self.port + } + + // MARK: - CDP message helpers + + fn evaluate_js(&mut self, expression: &str) -> Result { + let id = self.msg_id.fetch_add(1, Ordering::Relaxed); + let msg = json!({ + "id": id, + "method": "Runtime.evaluate", + "params": { + "expression": expression, + "returnByValue": true, + } + }); + + self.ws + .send(tungstenite::Message::Text(msg.to_string())) + .map_err(|e| { + CommandError::system_fault("metro_ws_send", format!("WebSocket send: {e}")) + })?; + + // Read responses until we get ours. + for _ in 0..50 { + let response = self.ws.read().map_err(|e| { + CommandError::system_fault("metro_ws_read", format!("WebSocket read: {e}")) + })?; + + if let tungstenite::Message::Text(text) = response { + if let Ok(val) = serde_json::from_str::(&text) { + if val["id"].as_u64() == Some(id as u64) { + if let Some(err) = val.get("error") { + return Err(CommandError::system_fault( + "metro_cdp_error", + format!("CDP error: {}", err), + )); + } + if let Some(result) = val["result"]["result"]["value"].as_str() { + return Ok(result.to_string()); + } + return Err(CommandError::system_fault( + "metro_no_result", + "CDP returned no value".to_string(), + )); + } + } + } + } + + Err(CommandError::system_fault( + "metro_response_timeout", + format!("no CDP response for message id {id} within 50 reads"), + )) + } +} + +// MARK: - Discovery + +/// Default ports to scan for Metro. +pub const METRO_PORT_RANGE: &[u16] = &[8081, 19000, 19001, 19002, 19003, 19004, 19005, 19006]; + +/// Probe ports for a running Metro bundler. Returns the first port that +/// responds to `GET /status`. +pub fn discover_metro(port_range: &[u16]) -> Option { + for &port in port_range { + if probe_metro_port(port) { + return Some(port); + } + } + None +} + +fn probe_metro_port(port: u16) -> bool { + let addr = format!("127.0.0.1:{port}"); + let Ok(mut stream) = TcpStream::connect_timeout( + &addr.parse().unwrap(), + Duration::from_millis(200), + ) else { + return false; + }; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + + let request = format!( + "GET /status HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + if stream.write_all(request.as_bytes()).is_err() { + return false; + } + + let mut buf = vec![0u8; 1024]; + let n = stream.read(&mut buf).unwrap_or(0); + let response = String::from_utf8_lossy(&buf[..n]); + // Metro responds with "packager-status:running" or similar. + response.contains("200") || response.contains("packager-status") +} + +/// Fetch the list of debuggable pages from Metro's /json/list endpoint. +fn fetch_pages(port: u16) -> Result, CommandError> { + let addr = format!("127.0.0.1:{port}"); + let mut stream = TcpStream::connect_timeout( + &addr.parse().unwrap(), + Duration::from_secs(5), + ) + .map_err(|e| { + CommandError::system_fault( + "metro_connect_failed", + format!("connect to Metro on port {port}: {e}"), + ) + })?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + + let request = format!( + "GET /json/list HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + stream.write_all(request.as_bytes()).map_err(|e| { + CommandError::system_fault("metro_request_failed", format!("send request: {e}")) + })?; + + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).ok(); + let response = String::from_utf8_lossy(&buf); + + // Extract JSON body after \r\n\r\n. + let body = response + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .trim(); + + // Handle chunked transfer encoding: strip chunk headers. + let json_body = if body.contains('[') { + let start = body.find('[').unwrap_or(0); + let end = body.rfind(']').map(|i| i + 1).unwrap_or(body.len()); + &body[start..end] + } else { + body + }; + + serde_json::from_str::>(json_body).map_err(|e| { + CommandError::system_fault( + "metro_parse_pages_failed", + format!("parse /json/list: {e} (body: {json_body})"), + ) + }) +} diff --git a/client/src-tauri/src/commands/emulator/automation/mod.rs b/client/src-tauri/src/commands/emulator/automation/mod.rs index 7ddc9c51..381ee860 100644 --- a/client/src-tauri/src/commands/emulator/automation/mod.rs +++ b/client/src-tauri/src/commands/emulator/automation/mod.rs @@ -19,6 +19,8 @@ pub mod android_ui; pub mod apps; pub mod ios_ui; pub mod logs; +pub mod metro_detect; +pub mod metro_inspector; pub mod selector; use serde::{Deserialize, Serialize}; diff --git a/client/src-tauri/src/commands/emulator/ios/session.rs b/client/src-tauri/src/commands/emulator/ios/session.rs index d43d7393..368834fc 100644 --- a/client/src-tauri/src/commands/emulator/ios/session.rs +++ b/client/src-tauri/src/commands/emulator/ios/session.rs @@ -142,29 +142,45 @@ impl IosSession { } fn send_touch(&self, phase: TouchPhase, nx: f32, ny: f32) -> Result<(), CommandError> { - // Prefer Swift helper (IndigoHID) — supports Moved/Ended phases - // unlike the CG/AppleScript path which is click-only. - if let Some(hc) = self.helper_client.as_ref() { - let (x, y) = input::denormalize(nx, ny, self.width.max(1), self.height.max(1)); - if let Ok(()) = hc.send_touch(phase, x, y) { - return Ok(()); + let (px, py) = input::denormalize(nx, ny, self.width.max(1), self.height.max(1)); + + match phase { + TouchPhase::Began => { + // Taps: CG/AppleScript click is the proven path on macOS 26. + // idb_companion 1.1.8 HID is broken on Xcode 26 (returns Ok + // but silently drops the event). Try CG first, then helper/idb. + let cg = cg_input::send_touch(&self.device_name, phase, nx, ny); + if cg.is_ok() { + return cg; + } + // CG failed (AX denied, window not found) — try helper, then idb. + if let Some(hc) = self.helper_client.as_ref() { + if hc.send_touch(phase, px, py).is_ok() { + return Ok(()); + } + } + if let Some(client) = self.client.as_ref() { + if client.send_hid(HidEvent::Touch { phase, x: px, y: py }).is_ok() { + return Ok(()); + } + } + cg + } + TouchPhase::Moved | TouchPhase::Ended | TouchPhase::Cancelled => { + // Drags/swipes: CG is a no-op for these phases. + // Helper (IndigoHID) and idb are the only paths that work. + if let Some(hc) = self.helper_client.as_ref() { + if hc.send_touch(phase, px, py).is_ok() { + return Ok(()); + } + } + if let Some(client) = self.client.as_ref() { + return client.send_hid(HidEvent::Touch { phase, x: px, y: py }); + } + // No helper or idb available — silent no-op (same as before). + Ok(()) } - // Fall through to CG/idb on helper failure. - } - - // CG path: on macOS 26 dispatches via AppleScript's AX `click at`. - // Only fires on TouchDown (Moved/Ended are no-ops). - let cg_result = cg_input::send_touch(&self.device_name, phase, nx, ny); - if cg_result.is_ok() || !should_try_idb_after_cg(cg_result.as_ref().unwrap_err()) { - return cg_result; - } - - if let Some(client) = self.client.as_ref() { - let (x, y) = input::denormalize(nx, ny, self.width.max(1), self.height.max(1)); - return client.send_hid(HidEvent::Touch { phase, x, y }); } - - cg_result } fn send_swipe( @@ -175,38 +191,37 @@ impl IosSession { to_y: f32, duration_ms: u32, ) -> Result<(), CommandError> { - // Prefer Swift helper (IndigoHID) for reliable swipe. + let width = self.width.max(1); + let height = self.height.max(1); + let (fx, fy) = input::denormalize(from_x, from_y, width, height); + let (tx, ty) = input::denormalize(to_x, to_y, width, height); + + // 1. Swift helper (IndigoHID) — most reliable. if let Some(hc) = self.helper_client.as_ref() { - let width = self.width.max(1); - let height = self.height.max(1); - let (fx, fy) = input::denormalize(from_x, from_y, width, height); - let (tx, ty) = input::denormalize(to_x, to_y, width, height); - if let Ok(()) = hc.send_swipe(fx, fy, tx, ty, duration_ms) { + if hc.send_swipe(fx, fy, tx, ty, duration_ms).is_ok() { return Ok(()); } } - let cg_result = - cg_input::send_swipe(&self.device_name, from_x, from_y, to_x, to_y, duration_ms); - if cg_result.is_ok() || !should_try_idb_after_cg(cg_result.as_ref().unwrap_err()) { - return cg_result; + // 2. CG/AppleScript drag — proven working path, try before idb + // because idb HID is broken on Xcode 26. + let cg = cg_input::send_swipe(&self.device_name, from_x, from_y, to_x, to_y, duration_ms); + if cg.is_ok() { + return cg; } + // 3. idb gRPC HID — last resort. if let Some(client) = self.client.as_ref() { - let width = self.width.max(1); - let height = self.height.max(1); - let (from_x_px, from_y_px) = input::denormalize(from_x, from_y, width, height); - let (to_x_px, to_y_px) = input::denormalize(to_x, to_y, width, height); return client.send_hid(HidEvent::Swipe { - from_x: from_x_px, - from_y: from_y_px, - to_x: to_x_px, - to_y: to_y_px, + from_x: fx, + from_y: fy, + to_x: tx, + to_y: ty, duration_ms, }); } - cg_result + cg } fn send_hid_or_cg( @@ -339,17 +354,6 @@ fn should_try_cg_fallback(err: &CommandError) -> bool { ) } -/// After a CGEvent send fails, only fall through to idb's HID path for -/// errors that mean "CG can't reach the Simulator window" — missing AX -/// permission or the Simulator window being absent. Other errors are bugs -/// in the CG path itself and retrying via idb won't help. -fn should_try_idb_after_cg(err: &CommandError) -> bool { - matches!( - err.code.as_str(), - "ios_ax_permission_denied" | "ios_simulator_window_not_found" - ) -} - impl Drop for IosSession { fn drop(&mut self) { self.shutdown(); diff --git a/client/src-tauri/src/commands/emulator/mod.rs b/client/src-tauri/src/commands/emulator/mod.rs index 2d819998..62dfad52 100644 --- a/client/src-tauri/src/commands/emulator/mod.rs +++ b/client/src-tauri/src/commands/emulator/mod.rs @@ -43,6 +43,9 @@ use automation::{ LocationRequest, LogSubscribeRequest, PushNotificationRequest, ScreenshotResponse, Selector, SubscriptionToken, SwipeRequest, TapTarget, TypeRequest, UiTree, }; +use automation::metro_inspector::{ + ElementInfo, MetroInspector, MetroStatus, METRO_PORT_RANGE, +}; /// Process-wide emulator state. Holds the FrameBus (shared with the URI /// scheme handler) and the single active device session, if any. @@ -51,6 +54,8 @@ pub struct EmulatorState { active: Mutex>, log_collector: automation::logs::LogCollector, log_stream: Mutex>, + /// Metro inspector bridge (React Native / Expo). + metro_inspector: Mutex>, } enum LogStreamHandle { @@ -65,6 +70,7 @@ impl Default for EmulatorState { active: Mutex::new(None), log_collector: automation::logs::LogCollector::new(), log_stream: Mutex::new(None), + metro_inspector: Mutex::new(None), } } } @@ -1279,6 +1285,81 @@ impl ActiveDevice { } } +// ---------- Metro Inspector commands (Phase 2) ---------------------------- + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InspectorConnectRequest { + pub port: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InspectorElementAtRequest { + pub x: f32, + pub y: f32, +} + +/// Connect to the Metro inspector. Auto-discovers Metro on common ports +/// unless an explicit port is provided. +#[tauri::command] +pub fn emulator_inspector_connect( + state: State<'_, EmulatorState>, + request: InspectorConnectRequest, +) -> CommandResult { + // Disconnect any existing connection first. + let _ = state.metro_inspector.lock().unwrap().take(); + + let (inspector, status) = if let Some(port) = request.port { + MetroInspector::connect(port)? + } else { + MetroInspector::connect_auto(METRO_PORT_RANGE)? + }; + + *state.metro_inspector.lock().unwrap() = Some(inspector); + Ok(status) +} + +/// Disconnect from the Metro inspector. +#[tauri::command] +pub fn emulator_inspector_disconnect( + state: State<'_, EmulatorState>, +) -> CommandResult<()> { + let _ = state.metro_inspector.lock().unwrap().take(); + Ok(()) +} + +/// Query the element at a device-pixel coordinate via the Metro inspector. +#[tauri::command] +pub fn emulator_inspector_element_at( + state: State<'_, EmulatorState>, + request: InspectorElementAtRequest, +) -> CommandResult { + let mut guard = state.metro_inspector.lock().unwrap(); + let inspector = guard.as_mut().ok_or_else(|| { + CommandError::user_fixable( + "metro_not_connected", + "Metro inspector is not connected. Click 'Inspect' to connect.".to_string(), + ) + })?; + inspector.element_at_point(request.x, request.y) +} + +/// Get the full React component tree via the Metro inspector. +#[tauri::command] +pub fn emulator_inspector_component_tree( + state: State<'_, EmulatorState>, +) -> CommandResult { + let mut guard = state.metro_inspector.lock().unwrap(); + let inspector = guard.as_mut().ok_or_else(|| { + CommandError::user_fixable( + "metro_not_connected", + "Metro inspector is not connected.".to_string(), + ) + })?; + inspector.component_tree() +} + fn uuid_like() -> String { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(1); diff --git a/client/src-tauri/src/db/migrations.rs b/client/src-tauri/src/db/migrations.rs index 2ab319e2..8e9ed8f5 100644 --- a/client/src-tauri/src/db/migrations.rs +++ b/client/src-tauri/src/db/migrations.rs @@ -405,7 +405,7 @@ const BASELINE_SCHEMA_SQL: &str = r#" created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), PRIMARY KEY (project_id, run_id), CHECK (agent_session_id <> ''), - CHECK (runtime_agent_id IN ('ask', 'engineer', 'debug')), + CHECK (runtime_agent_id IN ('ask', 'engineer')), CHECK (run_id <> ''), CHECK (provider_id <> ''), CHECK (model_id <> ''), diff --git a/client/src-tauri/src/db/project_store/agent_core.rs b/client/src-tauri/src/db/project_store/agent_core.rs index 48ba47f4..0e255a5f 100644 --- a/client/src-tauri/src/db/project_store/agent_core.rs +++ b/client/src-tauri/src/db/project_store/agent_core.rs @@ -1694,7 +1694,6 @@ fn parse_agent_run_status(value: &str) -> AgentRunStatus { fn parse_runtime_agent_id(value: &str) -> RuntimeAgentIdDto { match value { "engineer" => RuntimeAgentIdDto::Engineer, - "debug" => RuntimeAgentIdDto::Debug, _ => RuntimeAgentIdDto::Ask, } } diff --git a/client/src-tauri/src/db/project_store/project_record.rs b/client/src-tauri/src/db/project_store/project_record.rs index 058f07f7..346fac61 100644 --- a/client/src-tauri/src/db/project_store/project_record.rs +++ b/client/src-tauri/src/db/project_store/project_record.rs @@ -525,48 +525,4 @@ mod tests { assert!(lance_dir.join("project_records.lance").exists()); assert!(!repo_root.join(".xero").exists()); } - - #[test] - fn project_records_round_trip_debug_runtime_agent() { - project_record_lance::reset_connection_cache_for_tests(); - let tempdir = tempfile::tempdir().expect("temp dir"); - let repo_root = tempdir.path().join("repo"); - fs::create_dir_all(&repo_root).expect("repo dir"); - let project_id = "project-debug-records"; - create_project_database(&repo_root, project_id); - let mut record = new_project_record( - project_id, - "project-record-debug-1", - "Debug found the root cause, fixed it, and verified the regression test.", - ); - record.runtime_agent_id = RuntimeAgentIdDto::Debug; - record.title = "Debug run handoff".into(); - record.summary = "Debug found the root cause and verified the fix.".into(); - record.schema_name = Some("xero.project_record.debug_session.v1".into()); - record.content_json = Some(json!({ - "schema": "xero.project_record.debug_session.v1", - "debugSession": { - "memoryFocus": ["rootCause", "fix", "verification"] - } - })); - record.importance = ProjectRecordImportance::High; - record.tags = vec![ - "debug".into(), - "debugging".into(), - "root-cause".into(), - "verification".into(), - ]; - - let inserted = insert_project_record(&repo_root, &record).expect("insert debug record"); - let records = list_project_records(&repo_root, project_id).expect("list records"); - - assert_eq!(inserted.runtime_agent_id, RuntimeAgentIdDto::Debug); - assert_eq!(records.len(), 1); - assert_eq!(records[0].runtime_agent_id, RuntimeAgentIdDto::Debug); - assert_eq!( - records[0].schema_name.as_deref(), - Some("xero.project_record.debug_session.v1") - ); - assert!(records[0].tags.iter().any(|tag| tag == "root-cause")); - } } diff --git a/client/src-tauri/src/db/project_store/project_record_lance.rs b/client/src-tauri/src/db/project_store/project_record_lance.rs index 5abed376..4e3194a3 100644 --- a/client/src-tauri/src/db/project_store/project_record_lance.rs +++ b/client/src-tauri/src/db/project_store/project_record_lance.rs @@ -545,7 +545,6 @@ fn optional_str(array: &StringArray, index: usize) -> Option { fn parse_runtime_agent_id(value: &str) -> RuntimeAgentIdDto { match value { "engineer" => RuntimeAgentIdDto::Engineer, - "debug" => RuntimeAgentIdDto::Debug, _ => RuntimeAgentIdDto::Ask, } } diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index e60e8978..b9ea6f58 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -338,6 +338,10 @@ pub fn configure_builder_with_state( commands::emulator::emulator_logs_subscribe, commands::emulator::emulator_logs_unsubscribe, commands::emulator::emulator_logs_get_recent, + commands::emulator::emulator_inspector_connect, + commands::emulator::emulator_inspector_disconnect, + commands::emulator::emulator_inspector_element_at, + commands::emulator::emulator_inspector_component_tree, commands::solana::solana_toolchain_install, commands::solana::solana_toolchain_install_status, commands::solana::solana_toolchain_status, diff --git a/client/src-tauri/src/runtime/agent_core/persistence.rs b/client/src-tauri/src/runtime/agent_core/persistence.rs index 1faf619b..32db3be1 100644 --- a/client/src-tauri/src/runtime/agent_core/persistence.rs +++ b/client/src-tauri/src/runtime/agent_core/persistence.rs @@ -74,7 +74,6 @@ pub(crate) fn capture_project_record_for_run( let final_text = latest_assistant_message .map(|message| message.content.trim()) .filter(|content| !content.is_empty()); - let is_debug_run = snapshot.run.runtime_agent_id == RuntimeAgentIdDto::Debug; let (record_kind, title, raw_text, visibility) = match final_text { Some(text) => ( project_store::ProjectRecordKind::AgentHandoff, @@ -94,13 +93,8 @@ pub(crate) fn capture_project_record_for_run( return Ok(()); } let summary = trim_project_record_summary(&text); - let schema_name = if is_debug_run { - "xero.project_record.debug_session.v1" - } else { - "xero.project_record.run_handoff.v1" - }; - let mut content = json!({ - "schema": schema_name, + let content = json!({ + "schema": "xero.project_record.run_handoff.v1", "runtimeAgentId": snapshot.run.runtime_agent_id.as_str(), "providerId": snapshot.run.provider_id.as_str(), "modelId": snapshot.run.model_id.as_str(), @@ -108,34 +102,6 @@ pub(crate) fn capture_project_record_for_run( "fileChanges": file_paths, "messageId": latest_assistant_message.map(|message| message.id), }); - if is_debug_run { - content["debugSession"] = json!({ - "memoryFocus": [ - "symptom", - "reproduction", - "evidence", - "hypotheses", - "rootCause", - "fix", - "verification", - "remainingRisks", - "reusableTroubleshootingFacts" - ], - "captureContract": "The final assistant handoff is expected to include the symptom, root cause, fix rationale, changed files, verification evidence, and durable troubleshooting facts.", - }); - } - let tags = if is_debug_run { - vec![ - snapshot.run.runtime_agent_id.as_str().into(), - "debugging".into(), - "troubleshooting".into(), - "root-cause".into(), - "fix".into(), - "verification".into(), - ] - } else { - vec![snapshot.run.runtime_agent_id.as_str().into()] - }; project_store::insert_project_record( repo_root, &project_store::NewProjectRecordRecord { @@ -151,15 +117,11 @@ pub(crate) fn capture_project_record_for_run( summary, text, content_json: Some(content), - schema_name: Some(schema_name.into()), + schema_name: Some("xero.project_record.run_handoff.v1".into()), schema_version: 1, - importance: if is_debug_run { - project_store::ProjectRecordImportance::High - } else { - project_store::ProjectRecordImportance::Normal - }, - confidence: Some(if is_debug_run { 0.9 } else { 0.8 }), - tags, + importance: project_store::ProjectRecordImportance::Normal, + confidence: Some(0.8), + tags: vec![snapshot.run.runtime_agent_id.as_str().into()], source_item_ids: latest_assistant_message .map(|message| vec![format!("agent_messages:{}", message.id)]) .unwrap_or_default(), diff --git a/client/src-tauri/src/runtime/agent_core/provider_loop.rs b/client/src-tauri/src/runtime/agent_core/provider_loop.rs index 1091a8c5..f371ad86 100644 --- a/client/src-tauri/src/runtime/agent_core/provider_loop.rs +++ b/client/src-tauri/src/runtime/agent_core/provider_loop.rs @@ -403,7 +403,7 @@ fn dynamic_tool_catalog_metadata( "examples": [format!("Call `{}` after exact MCP activation.", descriptor.name)], "riskClass": "external_capability_invoke", "effectClass": "external_service", - "allowedRuntimeAgents": [RuntimeAgentIdDto::Engineer.as_str(), RuntimeAgentIdDto::Debug.as_str()], + "allowedRuntimeAgents": [RuntimeAgentIdDto::Engineer.as_str()], "runtimeAvailable": true, "source": server_id, "trust": "connected_mcp_server", diff --git a/client/src-tauri/src/runtime/agent_core/state_machine.rs b/client/src-tauri/src/runtime/agent_core/state_machine.rs index 36fa8fbf..c9c27f4e 100644 --- a/client/src-tauri/src/runtime/agent_core/state_machine.rs +++ b/client/src-tauri/src/runtime/agent_core/state_machine.rs @@ -825,30 +825,6 @@ mod tests { assert!(matches!(gate, ToolBatchGate::RequirePlan { .. })); } - #[test] - fn debug_agent_uses_engineering_plan_gate() { - let snapshot = empty_snapshot(); - let mut debug_controls = controls(true); - debug_controls.active.runtime_agent_id = RuntimeAgentIdDto::Debug; - let classification = classify_agent_task( - "Debug the failing runtime state machine test", - &debug_controls, - ); - let gate = evaluate_tool_batch_gate( - &snapshot, - &debug_controls, - &classification, - &[AgentToolCall { - tool_call_id: "call-1".into(), - tool_name: AUTONOMOUS_TOOL_COMMAND.into(), - input: json!({ "argv": ["pnpm", "test"] }), - }], - ); - - assert!(classification.requires_plan); - assert!(matches!(gate, ToolBatchGate::RequirePlan { .. })); - } - #[test] fn ask_agent_does_not_require_engineering_plan_gate() { let snapshot = empty_snapshot(); diff --git a/client/src-tauri/src/runtime/agent_core/tool_descriptors.rs b/client/src-tauri/src/runtime/agent_core/tool_descriptors.rs index 8cef5174..e3d6b00c 100644 --- a/client/src-tauri/src/runtime/agent_core/tool_descriptors.rs +++ b/client/src-tauri/src/runtime/agent_core/tool_descriptors.rs @@ -221,18 +221,6 @@ fn base_policy_fragment(runtime_agent_id: RuntimeAgentIdDto) -> String { "Final response contract: include a brief summary, files changed, verification run, and blockers or follow-ups when they exist.", ] .join("\n"), - RuntimeAgentIdDto::Debug => [ - "You are Xero's Debug agent. Work directly in the imported repository with the Engineer tool surface, but optimize every run for root-cause analysis, reproducible evidence, high-signal fixes, and future debugging memory.", - "", - "Follow a structured debugging workflow: intake the symptom and expected behavior, identify the execution path, reproduce or tightly simulate the issue, keep an evidence ledger, form falsifiable hypotheses, run the smallest useful experiments, eliminate unsupported causes, implement the narrowest fix, and verify the original failure plus adjacent regressions. Treat code you just wrote with extra skepticism and prefer evidence over confidence.", - "", - "Persistence contract: Xero saves your final run handoff to the Lance-backed project record store. Make that handoff useful for future retrieval by naming the symptom, reproduction steps, root cause, changed files, fix rationale, verification commands and results, remaining risks, and any stable troubleshooting facts that should become reviewed memory. Do not include secrets.", - "", - "Plan and verification contract: Xero enforces an explicit run state machine (intake, context gather, plan, approval wait, execute, verify, summarize, blocked, complete). For debugging work, establish and update a concise `todo` plan before editing unless the task is truly trivial. Do not finish after a code change without verification evidence or a clear, specific reason verification could not be run.", - "", - "Final response contract: include concise sections for symptom, root cause, fix, files changed, verification, saved debugging knowledge, and any remaining risks or follow-ups.", - ] - .join("\n"), }; [ agent_contract.as_str(), @@ -261,9 +249,6 @@ fn tool_policy_fragment( RuntimeAgentIdDto::Engineer => format!( "Available tools: {tool_names}\n\nIf a relevant capability is not currently available, first call `tool_search` to find the smallest matching capability, then call `tool_access` to activate the smallest needed group or exact tool before proceeding. Use `todo` for meaningful multi-step planning state. If the `lsp` tool reports an `installSuggestion`, ask the user before running any candidate install command; use the command tool only after consent and normal operator approval.{browser_control_guidance}" ), - RuntimeAgentIdDto::Debug => format!( - "Available tools: {tool_names}\n\nIf a relevant diagnostic, inspection, verification, or editing capability is not currently available, first call `tool_search` to find the smallest matching capability, then call `tool_access` to activate the smallest needed group or exact tool before proceeding. Use `todo` for debugging hypotheses and verification checkpoints. Prefer read-only experiments before mutation, and keep every command tied to a concrete hypothesis or verification need. If the `lsp` tool reports an `installSuggestion`, ask the user before running any candidate install command; use the command tool only after consent and normal operator approval.{browser_control_guidance}" - ), } } @@ -2565,48 +2550,6 @@ mod tests { .contains("command tool only after consent")); } - #[test] - fn prompt_compiler_renders_debug_contract_and_engineering_tool_policy() { - let root = tempfile::tempdir().expect("temp dir"); - let controls_input = RuntimeRunControlInputDto { - runtime_agent_id: RuntimeAgentIdDto::Debug, - provider_profile_id: None, - model_id: OPENAI_CODEX_PROVIDER_ID.into(), - thinking_effort: None, - approval_mode: RuntimeRunApprovalModeDto::Suggest, - plan_mode_required: true, - }; - let controls = runtime_controls_from_request(Some(&controls_input)); - let registry = ToolRegistry::for_prompt( - root.path(), - "Find the root cause of this failing test.", - &controls, - ); - - let compilation = PromptCompiler::new( - root.path(), - None, - None, - RuntimeAgentIdDto::Debug, - BrowserControlPreferenceDto::Default, - registry.descriptors(), - ) - .compile() - .expect("compile prompt"); - - assert!(compilation.prompt.contains("You are Xero's Debug agent.")); - assert!(compilation.prompt.contains("structured debugging workflow")); - assert!(compilation - .prompt - .contains("Lance-backed project record store")); - assert!(compilation.prompt.contains("root cause")); - assert!(compilation.prompt.contains("Available tools:")); - assert!(compilation - .prompt - .contains("Use `todo` for debugging hypotheses and verification checkpoints")); - assert!(!compilation.prompt.contains("Available observe-only tools:")); - } - #[test] fn prompt_compiler_includes_nested_instruction_fragments_in_deterministic_order() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/client/src-tauri/src/runtime/agent_core/types.rs b/client/src-tauri/src/runtime/agent_core/types.rs index 8c7969b8..d0b5a8d7 100644 --- a/client/src-tauri/src/runtime/agent_core/types.rs +++ b/client/src-tauri/src/runtime/agent_core/types.rs @@ -235,7 +235,7 @@ impl ToolRegistry { } continue; } - if self.options.runtime_agent_id.allows_engineering_tools() { + if self.options.runtime_agent_id == RuntimeAgentIdDto::Engineer { if let Some(dynamic) = tool_runtime.dynamic_tool_descriptor(tool_name)? { descriptors_by_name.insert( dynamic.name.clone(), diff --git a/client/src-tauri/src/runtime/autonomous_tool_runtime/mod.rs b/client/src-tauri/src/runtime/autonomous_tool_runtime/mod.rs index 54042e1d..6276da8a 100644 --- a/client/src-tauri/src/runtime/autonomous_tool_runtime/mod.rs +++ b/client/src-tauri/src/runtime/autonomous_tool_runtime/mod.rs @@ -554,7 +554,7 @@ pub fn tool_effect_class(tool_name: &str) -> AutonomousToolEffectClass { pub fn tool_allowed_for_runtime_agent(agent_id: RuntimeAgentIdDto, tool_name: &str) -> bool { match agent_id { - RuntimeAgentIdDto::Engineer | RuntimeAgentIdDto::Debug => true, + RuntimeAgentIdDto::Engineer => true, RuntimeAgentIdDto::Ask => { matches!(tool_name, AUTONOMOUS_TOOL_TOOL_ACCESS) || tool_effect_class(tool_name).is_ask_observe_only() @@ -570,9 +570,6 @@ fn allowed_runtime_agent_labels(tool_name: &str) -> Vec<&'static str> { if tool_allowed_for_runtime_agent(RuntimeAgentIdDto::Engineer, tool_name) { agents.push(RuntimeAgentIdDto::Engineer.as_str()); } - if tool_allowed_for_runtime_agent(RuntimeAgentIdDto::Debug, tool_name) { - agents.push(RuntimeAgentIdDto::Debug.as_str()); - } agents } @@ -1823,7 +1820,7 @@ impl AutonomousToolRuntime { let runtime_tool_available = known_tools.contains(tool.as_str()) && self.tool_available_by_runtime(tool.as_str()) && tool_allowed_for_runtime_agent(runtime_agent_id, tool.as_str()); - let dynamic_tool_available = runtime_agent_id.allows_engineering_tools() + let dynamic_tool_available = runtime_agent_id == RuntimeAgentIdDto::Engineer && self.dynamic_tool_descriptor(&tool)?.is_some(); if runtime_tool_available || dynamic_tool_available { requested.insert(tool); diff --git a/client/src-tauri/src/runtime/autonomous_tool_runtime/priority_tools.rs b/client/src-tauri/src/runtime/autonomous_tool_runtime/priority_tools.rs index d3a4a9fe..84c8c465 100644 --- a/client/src-tauri/src/runtime/autonomous_tool_runtime/priority_tools.rs +++ b/client/src-tauri/src/runtime/autonomous_tool_runtime/priority_tools.rs @@ -113,7 +113,7 @@ impl AutonomousToolRuntime { } } - if runtime_agent_id.allows_engineering_tools() { + if runtime_agent_id == RuntimeAgentIdDto::Engineer { let mcp_matches = self.mcp_tool_search_matches(&query, &query_terms)?; searched_catalog_size = searched_catalog_size.saturating_add(mcp_matches.len()); matches.extend(mcp_matches); diff --git a/client/src/App.tsx b/client/src/App.tsx index 742d8876..7c5c9773 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,7 +22,6 @@ import { SolanaWorkbenchSidebar } from '@/components/xero/solana-workbench-sideb import { SettingsDialog, type SettingsSection } from '@/components/xero/settings-dialog' import { UsageStatsSidebar } from '@/components/xero/usage-stats-sidebar' import { VcsSidebar, type VcsCommitMessageModel } from '@/components/xero/vcs-sidebar' -import { WorkflowsSidebar } from '@/components/xero/workflows-sidebar' import { XeroDesktopAdapter as DefaultXeroDesktopAdapter, type XeroDesktopAdapter } from '@/src/lib/xero-desktop' import { mapAgentSession, type RuntimeRunControlInputDto } from '@/src/lib/xero-model/runtime' import type { @@ -240,7 +239,6 @@ export function XeroApp({ adapter }: XeroAppProps) { const [androidOpen, setAndroidOpen] = useState(false) const [solanaOpen, setSolanaOpen] = useState(false) const [vcsOpen, setVcsOpen] = useState(false) - const [workflowsOpen, setWorkflowsOpen] = useState(false) const [usageOpen, setUsageOpen] = useState(false) const [environmentDiscoveryStatus, setEnvironmentDiscoveryStatus] = useState(null) @@ -318,7 +316,6 @@ export function XeroApp({ adapter }: XeroAppProps) { setAndroidOpen(false) setSolanaOpen(false) setVcsOpen(false) - setWorkflowsOpen(false) } return next }) @@ -333,7 +330,6 @@ export function XeroApp({ adapter }: XeroAppProps) { setAndroidOpen(false) setSolanaOpen(false) setVcsOpen(false) - setWorkflowsOpen(false) } return next }) @@ -348,7 +344,6 @@ export function XeroApp({ adapter }: XeroAppProps) { setAndroidOpen(false) setSolanaOpen(false) setVcsOpen(false) - setWorkflowsOpen(false) } return next }) @@ -363,7 +358,6 @@ export function XeroApp({ adapter }: XeroAppProps) { setIosOpen(false) setSolanaOpen(false) setVcsOpen(false) - setWorkflowsOpen(false) } return next }) @@ -378,7 +372,6 @@ export function XeroApp({ adapter }: XeroAppProps) { setIosOpen(false) setAndroidOpen(false) setVcsOpen(false) - setWorkflowsOpen(false) } return next }) @@ -393,22 +386,6 @@ export function XeroApp({ adapter }: XeroAppProps) { setIosOpen(false) setAndroidOpen(false) setSolanaOpen(false) - setWorkflowsOpen(false) - } - return next - }) - } - - const toggleWorkflows = () => { - setWorkflowsOpen((current) => { - const next = !current - if (next) { - setGamesOpen(false) - setBrowserOpen(false) - setIosOpen(false) - setAndroidOpen(false) - setSolanaOpen(false) - setVcsOpen(false) } return next }) @@ -440,10 +417,8 @@ export function XeroApp({ adapter }: XeroAppProps) { const [onboardingOpen, setOnboardingOpen] = useState(false) const shouldRestoreSidebarFromAutoCollapseRef = useRef(false) const shouldRestoreExplorerFromAutoCollapseRef = useRef(false) - const shouldRestoreSidebarFromWorkflowsRef = useRef(false) const previousViewRef = useRef(activeView) const previousBrowserOpenRef = useRef(browserOpen) - const previousWorkflowsOpenRef = useRef(workflowsOpen) useEffect(() => { const wasBrowserOpen = previousBrowserOpenRef.current @@ -528,28 +503,6 @@ export function XeroApp({ adapter }: XeroAppProps) { previousViewRef.current = activeView }, [activeView, sidebarCollapsed]) - useEffect(() => { - const wasOpen = previousWorkflowsOpenRef.current - - if (workflowsOpen && !wasOpen) { - shouldRestoreSidebarFromWorkflowsRef.current = !sidebarCollapsed - if (!sidebarCollapsed) { - setSidebarCollapsed(true) - } - } else if ( - !workflowsOpen && - wasOpen && - shouldRestoreSidebarFromWorkflowsRef.current - ) { - shouldRestoreSidebarFromWorkflowsRef.current = false - if (sidebarCollapsed) { - setSidebarCollapsed(false) - } - } - - previousWorkflowsOpenRef.current = workflowsOpen - }, [workflowsOpen, sidebarCollapsed]) - useEffect(() => { if (!onboardingDismissed && !isLoading && projects.length === 0) { setOnboardingOpen(true) @@ -706,11 +659,6 @@ export function XeroApp({ adapter }: XeroAppProps) { isStartingRun={agentView?.runtimeRunActionStatus === 'running'} onOpenSettings={() => openSettings('providers')} onStartRun={() => startRuntimeRun()} - onToggleWorkflows={toggleWorkflows} - workflowsOpen={workflowsOpen} - onCreateWorkflow={() => { - if (!workflowsOpen) toggleWorkflows() - }} /> ) : null} @@ -844,8 +792,6 @@ export function XeroApp({ adapter }: XeroAppProps) { solanaOpen={solanaOpen} onToggleVcs={toggleVcs} vcsOpen={vcsOpen} - onToggleWorkflows={toggleWorkflows} - workflowsOpen={workflowsOpen} vcsChangeCount={repositoryStatus?.statusCount ?? 0} vcsAdditions={repositoryStatus?.additions ?? 0} vcsDeletions={repositoryStatus?.deletions ?? 0} @@ -919,8 +865,6 @@ export function XeroApp({ adapter }: XeroAppProps) { solanaOpen={solanaOpen} onToggleVcs={toggleVcs} vcsOpen={vcsOpen} - onToggleWorkflows={toggleWorkflows} - workflowsOpen={workflowsOpen} vcsChangeCount={repositoryStatus?.statusCount ?? 0} vcsAdditions={repositoryStatus?.additions ?? 0} vcsDeletions={repositoryStatus?.deletions ?? 0} @@ -966,7 +910,6 @@ export function XeroApp({ adapter }: XeroAppProps) { - + source: SourceLocation | null +} + +export interface MetroStatus { + connected: boolean + port: number + pages: Array<{ id: string; title: string; vm: string; description: string }> +} + +export interface UseInspector { + /** Whether inspect mode is active. */ + inspectMode: boolean + /** Toggle inspect mode on/off. */ + toggleInspect: () => void + /** Whether the Metro inspector is connected. */ + metroConnected: boolean + /** Status of the Metro connection. */ + metroStatus: MetroStatus | null + /** The element currently hovered (null if none). */ + hoveredElement: ElementInfo | null + /** Error from the inspector. */ + inspectError: string | null + /** Connect to Metro inspector (auto-discover or explicit port). */ + connect: (port?: number) => Promise + /** Disconnect from Metro inspector. */ + disconnect: () => Promise + /** Query element at a point (device pixels). */ + elementAt: (x: number, y: number) => Promise + /** Get the full component tree. */ + componentTree: () => Promise +} + +/** + * Hook for Metro inspector integration. Provides element-at-point + * inspection, source mapping, and highlight overlays for React Native + * and Expo apps. + */ +export function useInspector(): UseInspector { + const [inspectMode, setInspectMode] = useState(false) + const [metroStatus, setMetroStatus] = useState(null) + const [hoveredElement, setHoveredElement] = useState(null) + const [inspectError, setInspectError] = useState(null) + + // Debounce timer for element-at-point queries. + const debounceRef = useRef | null>(null) + + const toggleInspect = useCallback(() => { + setInspectMode((prev) => { + if (prev) { + // Turning off — clear hovered element. + setHoveredElement(null) + } + return !prev + }) + }, []) + + const connect = useCallback(async (port?: number) => { + if (!isTauri()) return + setInspectError(null) + try { + const status = await invoke("emulator_inspector_connect", { + request: { port: port ?? null }, + }) + setMetroStatus(status) + } catch (err) { + setInspectError(errorMessage(err)) + setMetroStatus(null) + } + }, []) + + const disconnect = useCallback(async () => { + if (!isTauri()) return + try { + await invoke("emulator_inspector_disconnect") + } finally { + setMetroStatus(null) + setHoveredElement(null) + } + }, []) + + const elementAt = useCallback(async (x: number, y: number): Promise => { + if (!isTauri()) return null + try { + const info = await invoke("emulator_inspector_element_at", { + request: { x, y }, + }) + setHoveredElement(info) + setInspectError(null) + return info + } catch (err) { + // Transient — don't flood UI with errors on hover. + setHoveredElement(null) + return null + } + }, []) + + const elementAtDebounced = useCallback( + (x: number, y: number) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + return new Promise((resolve) => { + debounceRef.current = setTimeout(async () => { + resolve(await elementAt(x, y)) + }, 80) + }) + }, + [elementAt], + ) + + const componentTree = useCallback(async () => { + if (!isTauri()) return null + try { + return await invoke("emulator_inspector_component_tree") + } catch (err) { + setInspectError(errorMessage(err)) + return null + } + }, []) + + return { + inspectMode, + toggleInspect, + metroConnected: metroStatus?.connected ?? false, + metroStatus, + hoveredElement, + inspectError, + connect, + disconnect, + elementAt: elementAtDebounced, + componentTree, + } +} + +function errorMessage(err: unknown): string { + if (err && typeof err === "object" && "message" in err) { + const msg = (err as { message?: unknown }).message + if (typeof msg === "string" && msg.length > 0) return msg + } + if (typeof err === "string" && err.length > 0) return err + return "Inspector error" +} diff --git a/client/src/features/xero/live-views.test.tsx b/client/src/features/xero/live-views.test.tsx index c5980859..8cf20a5a 100644 --- a/client/src/features/xero/live-views.test.tsx +++ b/client/src/features/xero/live-views.test.tsx @@ -22,13 +22,12 @@ import type { WorkflowPaneView, } from '@/src/features/xero/use-xero-desktop-state' import type { AgentProviderModelCatalogView } from '@/src/features/xero/use-xero-desktop-state/types' -import { - getRuntimeAgentLabel, - type ProjectDetailView, - type ProviderModelThinkingEffortDto, - type RuntimeRunView, - type RuntimeSessionView, - type RuntimeStreamView, +import type { + ProjectDetailView, + ProviderModelThinkingEffortDto, + RuntimeRunView, + RuntimeSessionView, + RuntimeStreamView, } from '@/src/lib/xero-model' type CheckpointControlLoopCard = NonNullable['items'][number] @@ -487,7 +486,8 @@ function makeAgent(project = makeProject(), overrides: Partial = controlTruthSource: overrides.controlTruthSource ?? (selectedControls && !runtimeRun?.isTerminal ? 'runtime_run' : 'fallback'), selectedRuntimeAgentId, - selectedRuntimeAgentLabel: overrides.selectedRuntimeAgentLabel ?? getRuntimeAgentLabel(selectedRuntimeAgentId), + selectedRuntimeAgentLabel: + overrides.selectedRuntimeAgentLabel ?? (selectedRuntimeAgentId === 'engineer' ? 'Engineer' : 'Ask'), selectedModelId: overrides.selectedModelId ?? selectedControls?.modelId ?? selectedModelOption?.modelId ?? null, selectedThinkingEffort: overrides.selectedThinkingEffort ?? diff --git a/client/src/lib/xero-model/runtime.test.ts b/client/src/lib/xero-model/runtime.test.ts index d133f5da..29b91f0a 100644 --- a/client/src/lib/xero-model/runtime.test.ts +++ b/client/src/lib/xero-model/runtime.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { - getRuntimeAgentDescriptor, mapRuntimeRun, - RUNTIME_AGENT_DESCRIPTORS, - runtimeAgentIdSchema, runtimeRunSchema, startRuntimeRunRequestSchema, updateRuntimeRunControlsRequestSchema, @@ -59,21 +56,6 @@ function makeRuntimeRunDto(overrides: Record = {}) { } describe('runtime run control schemas', () => { - it('registers Debug as an engineering-capable runtime agent', () => { - expect(runtimeAgentIdSchema.parse('debug')).toBe('debug') - expect(RUNTIME_AGENT_DESCRIPTORS.map((agent) => agent.id)).toEqual(['ask', 'engineer', 'debug']) - - expect(getRuntimeAgentDescriptor('debug')).toMatchObject({ - id: 'debug', - label: 'Debug', - toolPolicy: 'engineering', - outputContract: 'debug_summary', - allowPlanGate: true, - allowVerificationGate: true, - allowedApprovalModes: ['suggest', 'auto_edit', 'yolo'], - }) - }) - it('maps durable active and pending control snapshots into a selected pending projection', () => { const parsed = runtimeRunSchema.parse(makeRuntimeRunDto()) const view = mapRuntimeRun(parsed) diff --git a/client/src/lib/xero-model/runtime.ts b/client/src/lib/xero-model/runtime.ts index 8a8e3b55..f10ab807 100644 --- a/client/src/lib/xero-model/runtime.ts +++ b/client/src/lib/xero-model/runtime.ts @@ -44,7 +44,7 @@ export const writableRuntimeSettingsProviderIdSchema = z.enum(['openrouter', 'op export const runtimeRunThinkingEffortSchema = z.enum(['minimal', 'low', 'medium', 'high', 'x_high']) export const runtimeRunApprovalModeSchema = z.enum(['suggest', 'auto_edit', 'yolo']) export const DEFAULT_RUNTIME_RUN_APPROVAL_MODE: RuntimeRunApprovalModeDto = 'suggest' -export const runtimeAgentIdSchema = z.enum(['ask', 'engineer', 'debug']) +export const runtimeAgentIdSchema = z.enum(['ask', 'engineer']) export const DEFAULT_RUNTIME_AGENT_ID: RuntimeAgentIdDto = 'ask' export interface RuntimeAgentDescriptor { @@ -55,9 +55,9 @@ export interface RuntimeAgentDescriptor { taskPurpose: string defaultApprovalMode: RuntimeRunApprovalModeDto allowedApprovalModes: readonly RuntimeRunApprovalModeDto[] - promptPolicy: 'ask' | 'engineer' | 'debug' + promptPolicy: 'ask' | 'engineer' toolPolicy: 'observe_only' | 'engineering' - outputContract: 'answer' | 'engineering_summary' | 'debug_summary' + outputContract: 'answer' | 'engineering_summary' projectDataPolicy: { required: true recordKinds: readonly ( @@ -142,43 +142,6 @@ export const RUNTIME_AGENT_DESCRIPTORS = [ allowVerificationGate: true, allowAutoCompact: true, }, - { - id: 'debug', - label: 'Debug', - shortLabel: 'Debug', - description: - 'Investigate failures with structured evidence, hypotheses, fixes, verification, and durable debugging memory.', - taskPurpose: - 'Reproduce, gather evidence, test hypotheses, isolate root cause, fix, verify, and preserve reusable debugging knowledge.', - defaultApprovalMode: 'suggest', - allowedApprovalModes: ['suggest', 'auto_edit', 'yolo'], - promptPolicy: 'debug', - toolPolicy: 'engineering', - outputContract: 'debug_summary', - projectDataPolicy: { - required: true, - recordKinds: [ - 'agent_handoff', - 'project_fact', - 'decision', - 'constraint', - 'plan', - 'finding', - 'verification', - 'question', - 'artifact', - 'context_note', - 'diagnostic', - ], - structuredSchemas: ['xero.project_record.v1', 'xero.project_record.debug_session.v1'], - unstructuredScopes: ['answer_note', 'session_summary', 'artifact_excerpt', 'troubleshooting_note'], - memoryCandidateKinds: ['project_fact', 'user_preference', 'decision', 'session_summary', 'troubleshooting'], - }, - workflowRole: 'interactive', - allowPlanGate: true, - allowVerificationGate: true, - allowAutoCompact: true, - }, ] as const satisfies readonly RuntimeAgentDescriptor[] export function getRuntimeAgentDescriptor(agentId: RuntimeAgentIdDto): RuntimeAgentDescriptor { From f9f5ec39d0793d3afecaeeba53074fc635c1badd Mon Sep 17 00:00:00 2001 From: "Md.Sadiq" Date: Sun, 3 May 2026 00:38:15 +0530 Subject: [PATCH 3/5] uffff solved merge conflict --- AGENT_CREATE_PLAN.md | 398 +++ AGENT_RUNTIME_CONVERSATION_FIX_PLAN.md | 261 ++ PLATFORM_SUPPORT_AUDIT_AND_PLAN.md | 82 - README.md | 6 +- client/components/xero/agent-runtime.test.tsx | 997 ++++---- client/components/xero/agent-runtime.tsx | 761 +++++- .../agent-context-meter.test.tsx | 203 ++ .../agent-runtime/agent-context-meter.tsx | 157 ++ .../agent-create-draft-section.test.tsx | 84 + .../agent-create-draft-section.tsx | 135 + .../checkpoint-control-loop-helpers.ts | 310 --- .../checkpoint-control-loop-section.tsx | 519 ---- .../xero/agent-runtime/composer-dock.tsx | 171 +- .../agent-runtime/composer-helpers.test.ts | 125 + .../xero/agent-runtime/composer-helpers.ts | 93 +- .../agent-runtime/conversation-section.tsx | 534 +++- .../xero/agent-runtime/helpers.test.ts | 297 +-- .../components/xero/agent-runtime/helpers.ts | 1 - .../agent-runtime/memory-review-section.tsx | 438 ---- .../recovered-runtime-section.tsx | 196 -- .../agent-runtime/runtime-stream-helpers.ts | 261 ++ .../use-agent-runtime-controller.ts | 83 +- .../xero/agent-sessions-sidebar.test.tsx | 39 + .../xero/agent-sessions-sidebar.tsx | 50 +- .../components/xero/execution-view.test.tsx | 43 + client/components/xero/execution-view.tsx | 3 + .../use-execution-workspace-controller.ts | 14 +- client/components/xero/project-rail.test.tsx | 31 +- client/components/xero/project-rail.tsx | 86 +- .../components/xero/settings-dialog.test.tsx | 97 +- client/components/xero/settings-dialog.tsx | 40 +- .../settings-dialog/agents-section.test.tsx | 193 ++ .../xero/settings-dialog/agents-section.tsx | 488 ++++ .../settings-dialog/development-section.tsx | 40 +- .../xero/settings-dialog/mcp-section.tsx | 6 +- .../xero/settings-dialog/soul-section.tsx | 255 ++ client/components/xero/vcs-sidebar.test.tsx | 54 +- client/components/xero/vcs-sidebar.tsx | 1 + .../crates/cookie-importer/Cargo.lock | 20 +- .../crates/cookie-importer/src/main.rs | 3 +- client/src-tauri/resources/xero-ios-helper | Bin 161392 -> 161392 bytes .../src-tauri/src/auth/openai_compatible.rs | 19 + client/src-tauri/src/auth/openrouter.rs | 17 + .../src-tauri/src/bin/xero_harness_evals.rs | 4 +- .../src/commands/agent_definition.rs | 64 + .../src-tauri/src/commands/agent_session.rs | 51 +- .../src/commands/agent_session_title.rs | 391 +++ client/src-tauri/src/commands/agent_task.rs | 52 +- .../src-tauri/src/commands/contracts/agent.rs | 171 +- .../src/commands/contracts/runtime.rs | 257 +- .../src/commands/contracts/session_context.rs | 295 ++- .../src/commands/contracts/surface.rs | 2 + .../src/commands/git_commit_message.rs | 3 + client/src-tauri/src/commands/mod.rs | 12 + .../src/commands/runtime_support/mod.rs | 9 +- .../src/commands/runtime_support/run.rs | 117 +- .../src-tauri/src/commands/session_history.rs | 31 +- .../src-tauri/src/commands/soul_settings.rs | 311 +++ .../src/commands/subscribe_runtime_stream.rs | 670 ++++- .../commands/update_runtime_run_controls.rs | 97 +- client/src-tauri/src/db/migrations.rs | 617 ++++- .../src/db/project_store/agent_continuity.rs | 2239 +++++++++++++++++ .../src/db/project_store/agent_core.rs | 129 +- .../src/db/project_store/agent_definition.rs | 806 ++++++ .../src/db/project_store/agent_embeddings.rs | 221 ++ .../src/db/project_store/agent_lineage.rs | 12 +- .../src/db/project_store/agent_memory.rs | 15 + .../db/project_store/agent_memory_lance.rs | 212 +- .../src/db/project_store/agent_retrieval.rs | 1399 ++++++++++ .../src/db/project_store/agent_session.rs | 219 +- client/src-tauri/src/db/project_store/mod.rs | 8 + .../src/db/project_store/project_record.rs | 37 + .../db/project_store/project_record_lance.rs | 217 +- .../src-tauri/src/db/project_store/runtime.rs | 67 +- client/src-tauri/src/global_db/migrations.rs | 15 + client/src-tauri/src/global_db/mod.rs | 1 + client/src-tauri/src/lib.rs | 6 + client/src-tauri/src/provider_models/mod.rs | 148 +- .../src/runtime/agent_core/context_package.rs | 720 ++++++ .../src-tauri/src/runtime/agent_core/evals.rs | 1485 +++++++++++ .../src-tauri/src/runtime/agent_core/mod.rs | 36 +- .../src/runtime/agent_core/persistence.rs | 900 +++++++ .../runtime/agent_core/provider_adapters.rs | 212 +- .../src/runtime/agent_core/provider_loop.rs | 40 +- .../src-tauri/src/runtime/agent_core/run.rs | 1585 +++++++++++- .../src/runtime/agent_core/state_machine.rs | 5 + .../runtime/agent_core/tool_descriptors.rs | 805 +++++- .../src-tauri/src/runtime/agent_core/types.rs | 71 +- .../agent_definition.rs | 1722 +++++++++++++ .../autonomous_tool_runtime/filesystem.rs | 81 +- .../runtime/autonomous_tool_runtime/mod.rs | 318 ++- .../runtime/autonomous_tool_runtime/policy.rs | 64 +- .../process_manager.rs | 2 + .../project_context.rs | 1123 +++++++++ .../autonomous_tool_runtime/repo_scope.rs | 11 + client/src-tauri/src/runtime/mod.rs | 81 +- .../src-tauri/src/runtime/supervisor/mod.rs | 2 + .../tests/agent_context_continuity.rs | 1502 +++++++++++ client/src-tauri/tests/agent_core_runtime.rs | 206 +- .../tests/autonomous_imported_repo_bridge.rs | 2 + .../tests/autonomous_skill_model_tool.rs | 3 + .../tests/autonomous_tool_runtime.rs | 22 +- .../src-tauri/tests/project_usage_summary.rs | 4 + .../tests/provider_diagnostics_contract.rs | 5 + .../runtime_run_persistence/runtime_rows.rs | 4 +- .../tests/session_context_contract.rs | 5 + .../tests/session_history_commands.rs | 4 + client/src/App.test.tsx | 37 +- client/src/App.tsx | 103 +- .../xero/agent-runtime-projections.test.ts | 191 -- .../xero/agent-runtime-projections.ts | 9 - .../checkpoint-control-loops.ts | 885 ------- .../xero/agent-runtime-projections/shared.ts | 35 - client/src/features/xero/live-views.test.tsx | 250 +- ...se-xero-desktop-state.runtime-run.test.tsx | 129 +- .../xero/use-xero-desktop-state.test.tsx | 174 +- .../features/xero/use-xero-desktop-state.ts | 690 ++++- .../agent-session-mutations.ts | 73 +- .../mutation-support.ts | 11 + .../use-xero-desktop-state/project-loaders.ts | 133 +- .../run-control-mutations.ts | 38 +- .../runtime-provider-credentials.test.ts | 4 + .../runtime-provider.ts | 12 +- .../xero/use-xero-desktop-state/types.ts | 6 +- .../use-xero-desktop-state/view-builders.ts | 8 - client/src/lib/xero-desktop.dictation.test.ts | 31 +- client/src/lib/xero-desktop.ts | 109 +- client/src/lib/xero-model.test.ts | 270 +- client/src/lib/xero-model.ts | 2 + client/src/lib/xero-model/agent-definition.ts | 134 + client/src/lib/xero-model/agent.test.ts | 18 + client/src/lib/xero-model/agent.ts | 11 +- client/src/lib/xero-model/provider-models.ts | 9 + client/src/lib/xero-model/runtime-stream.ts | 136 +- client/src/lib/xero-model/runtime.test.ts | 54 + client/src/lib/xero-model/runtime.ts | 122 +- client/src/lib/xero-model/session-context.ts | 121 +- client/src/lib/xero-model/soul.ts | 33 + client/vite.config.ts | 16 + docs/agent-harness-benchmarking.md | 212 ++ docs/agent-runtime-continuity.md | 93 + 141 files changed, 27142 insertions(+), 4553 deletions(-) create mode 100644 AGENT_CREATE_PLAN.md create mode 100644 AGENT_RUNTIME_CONVERSATION_FIX_PLAN.md delete mode 100644 PLATFORM_SUPPORT_AUDIT_AND_PLAN.md create mode 100644 client/components/xero/agent-runtime/agent-context-meter.test.tsx create mode 100644 client/components/xero/agent-runtime/agent-context-meter.tsx create mode 100644 client/components/xero/agent-runtime/agent-create-draft-section.test.tsx create mode 100644 client/components/xero/agent-runtime/agent-create-draft-section.tsx delete mode 100644 client/components/xero/agent-runtime/checkpoint-control-loop-helpers.ts delete mode 100644 client/components/xero/agent-runtime/checkpoint-control-loop-section.tsx create mode 100644 client/components/xero/agent-runtime/composer-helpers.test.ts delete mode 100644 client/components/xero/agent-runtime/memory-review-section.tsx delete mode 100644 client/components/xero/agent-runtime/recovered-runtime-section.tsx create mode 100644 client/components/xero/settings-dialog/agents-section.test.tsx create mode 100644 client/components/xero/settings-dialog/agents-section.tsx create mode 100644 client/components/xero/settings-dialog/soul-section.tsx create mode 100644 client/src-tauri/src/commands/agent_definition.rs create mode 100644 client/src-tauri/src/commands/agent_session_title.rs create mode 100644 client/src-tauri/src/commands/soul_settings.rs create mode 100644 client/src-tauri/src/db/project_store/agent_continuity.rs create mode 100644 client/src-tauri/src/db/project_store/agent_definition.rs create mode 100644 client/src-tauri/src/db/project_store/agent_embeddings.rs create mode 100644 client/src-tauri/src/db/project_store/agent_retrieval.rs create mode 100644 client/src-tauri/src/runtime/agent_core/context_package.rs create mode 100644 client/src-tauri/src/runtime/autonomous_tool_runtime/agent_definition.rs create mode 100644 client/src-tauri/src/runtime/autonomous_tool_runtime/project_context.rs create mode 100644 client/src-tauri/tests/agent_context_continuity.rs delete mode 100644 client/src/features/xero/agent-runtime-projections.test.ts delete mode 100644 client/src/features/xero/agent-runtime-projections.ts delete mode 100644 client/src/features/xero/agent-runtime-projections/checkpoint-control-loops.ts delete mode 100644 client/src/features/xero/agent-runtime-projections/shared.ts create mode 100644 client/src/lib/xero-model/agent-definition.ts create mode 100644 client/src/lib/xero-model/soul.ts create mode 100644 docs/agent-harness-benchmarking.md create mode 100644 docs/agent-runtime-continuity.md diff --git a/AGENT_CREATE_PLAN.md b/AGENT_CREATE_PLAN.md new file mode 100644 index 00000000..916212c0 --- /dev/null +++ b/AGENT_CREATE_PLAN.md @@ -0,0 +1,398 @@ +# Agent Create Plan Draft + +Reader: an internal Xero engineer who will implement user-created agents. + +Post-read action: build a first-class built-in agent named **Agent Create** that can interview the user, draft a high-quality custom agent, validate it, persist it in app-data-backed state, and make the created agent usable through the same owned-agent runtime as Ask, Engineer, and Debug. + +Status: draft. + +## Goal + +Xero currently has three first-class runtime agents: Ask, Engineer, and Debug. That model is good, but the set is closed. Users should be able to create their own agents by talking to an agent. + +Add **Agent Create** as a fourth built-in runtime agent. Agent Create is not a thin prompt preset. It is a guided agent-design workflow that produces durable custom agent definitions with the same quality bar as the built-ins: explicit system contract, safe tool policy, LanceDB-backed project context and memory integration, context manifests, handoff support, usage tracking, validation, and tests. + +Custom agents should then appear in the normal agent selector and run through the normal owned-agent runtime. + +## Product Shape + +Agent Create is selected like Ask, Engineer, or Debug. + +The user describes the agent they want. Agent Create conducts a short design conversation, then produces an agent definition draft. The draft is shown to the user for review with clear user-facing fields: + +- Name and short label. +- Purpose and best-use cases. +- Default model and approval mode. +- Capabilities and tool access. +- Memory and retrieval behavior. +- Workflow instructions. +- Final response contract. +- Safety limits. +- Example prompts the agent should handle. + +The user can ask Agent Create to revise the draft. When the user approves it, Agent Create saves the agent. The saved agent becomes available for future runs. + +Created agents can be global or project-scoped. Prefer global as the default when the agent is general-purpose, and project-scoped when the definition depends on project-specific constraints, tools, memories, or terminology. + +## Design Principle + +Custom agents must be registry-backed runtime agents, not prompt snippets. + +The current architecture treats runtime agent identity as a closed set. User-created agents need a durable registry. Built-ins should become seeded registry definitions, and custom agents should use the same definition shape. Runtime runs should reference an agent definition id and a pinned definition version so old runs remain explainable after an agent is edited. + +## Agent Definition Contract + +Every built-in or custom agent definition should contain: + +- Stable id. +- Version. +- Display name. +- Short label. +- Description. +- Task purpose. +- Scope: built-in, global custom, or project custom. +- Lifecycle state: draft, active, archived. +- Base capability profile. +- Default approval mode. +- Allowed approval modes. +- Tool policy. +- Prompt fragments. +- Workflow contract. +- Final response contract. +- Project data policy. +- Memory candidate policy. +- Retrieval defaults. +- Handoff policy. +- Validation report. +- Created and updated metadata. + +Base capability profiles should remain small and explicit: + +- `observe_only`: Ask-like read-only behavior. +- `engineering`: Engineer-like repository mutation with plan, approval, and verification gates. +- `debugging`: Debug-like engineering behavior with stronger evidence and root-cause expectations. +- `agent_builder`: Agent Create's controlled app-data mutation profile. + +Custom agents choose one base capability profile and then narrow it with a tool policy. They should not get broader powers than the profile permits. + +## Agent Create Contract + +Agent Create should use the `agent_builder` capability profile. + +It can: + +- Read durable project context and approved memory. +- Inspect the available tool catalog and tool metadata. +- Draft an agent definition. +- Validate an agent definition. +- Run lightweight definition evaluations. +- Save, update, archive, and clone custom agent definitions after user approval. + +It cannot: + +- Edit repository files. +- Run shell commands. +- Start or stop processes. +- Control browsers or devices. +- Invoke arbitrary MCP or external-service tools. +- Create new tool implementations. +- Bypass approval or redaction policy. +- Persist unreviewed memory as approved memory. + +Agent Create can choose existing tool access for the new agent, but v1 should not let users generate new tools. New tool integrations should remain a separate engineering or plugin workflow. + +## Runtime Integration + +Replace the closed runtime-agent enum at the model boundary with registry-backed descriptors. + +Runs should store: + +- Selected agent definition id. +- Selected agent definition version. +- Base capability profile. +- Display label snapshot. +- Provider and model snapshot. +- Approval mode snapshot. + +The runtime should resolve the pinned definition before compiling a system prompt. If the definition is missing or archived, existing runs continue from the pinned snapshot stored with the run; new runs cannot start from inactive definitions. + +Prompt compilation should remain fragment-based: + +- Xero system policy. +- Agent definition policy. +- Active tool policy. +- Repository instructions. +- Project code map. +- Skill context. +- Owned process state. +- Approved memory. +- Retrieved project records. + +The agent definition policy fragment is where a custom agent's purpose, workflow, and final response contract live. It must never outrank Xero system policy, tool policy, user approvals, or repository instructions. + +## Tool Policy + +Tool access should be derived from the base capability profile, then narrowed by the agent definition. + +The policy shape should support: + +- Allowed tool groups. +- Allowed exact tools. +- Denied exact tools. +- Allowed effect classes. +- External-service allowance. +- Browser/device-control allowance. +- Skill-runtime allowance. +- Subagent allowance. +- Command allowance. +- Destructive-write allowance. +- Required approval modes for risky effects. + +Default custom agents to least privilege. Agent Create should ask before granting write, command, browser, device, skill, subagent, MCP, or external-service access. + +The runtime should validate the effective policy at run start and at tool dispatch. A custom agent definition must not be able to smuggle a broader policy through prompt text. + +## LanceDB, Retrieval, And Memory + +Custom agents should use the same Lance-backed project record and approved-memory stores as built-ins. + +Lance-backed records should continue to carry runtime agent metadata, but the metadata must support custom ids and definition versions. Retrieval filters should work for built-ins, custom agents, base capability profiles, sessions, tags, record kinds, memory kinds, related paths, recency, confidence, and importance. + +Agent Create should persist useful design outcomes as project records, not approved memory. Examples: + +- Why the agent exists. +- Chosen base capability profile. +- Tool policy rationale. +- Safety constraints. +- Example tasks. +- Evaluation results. + +Created agents should inherit normal memory behavior: + +- Approved memory is injected as lower-priority context. +- Memory candidates are extracted after completion, pause, failure, and handoff. +- Candidates remain disabled until reviewed. +- Prompt-injection-shaped or secret-bearing candidates are blocked or redacted. + +Agent definitions themselves should be stored in transactional app-data state, with Lance records used for retrieval and auditability. + +## Data Model + +Add an agent-definition registry in app-data-backed state. + +Suggested tables: + +- `agent_definitions`: current definition metadata and lifecycle state. +- `agent_definition_versions`: immutable version snapshots. +- `agent_definition_validation_runs`: validation and eval results. +- `agent_definition_usage`: optional aggregate usage and quality signals. + +Built-ins are seeded records in the same registry: + +- Ask. +- Engineer. +- Debug. +- Agent Create. + +Because this is a new application and backwards compatibility is not required, update the fresh schema baseline instead of carrying compatibility shims. Remove hard-coded checks that only allow `ask`, `engineer`, and `debug`; replace them with non-empty ids plus registry validation at command/runtime boundaries. Where foreign keys are practical, prefer them. Where LanceDB or denormalized snapshots need plain text, store the pinned id and version. + +Do not write new state under `.xero/`. Definitions, validation reports, Lance records, and usage history belong under the OS app-data-backed storage model. + +## User Interface + +Use ShadCN components where possible. + +Required surfaces: + +- Agent selector includes built-ins and active custom agents. +- Agent Create conversation can draft, revise, validate, and save an agent. +- A review surface shows the draft definition before activation. +- Agent management allows rename, clone, archive, delete, and version history. +- Project-scoped agents are visually distinct from global agents. +- Invalid or blocked definitions show actionable diagnostics. + +Do not add temporary development UI. Every UI surface should be user-facing. + +## Agent Creation Flow + +1. User selects Agent Create. +2. User describes the agent they want. +3. Agent Create gathers missing intent: purpose, scope, tools, risk tolerance, expected outputs, project specificity, and example tasks. +4. Agent Create retrieves relevant project context if the agent is project-specific. +5. Agent Create inspects the tool catalog and proposes the smallest safe capability set. +6. Agent Create drafts a definition. +7. Runtime validates the definition structurally and semantically. +8. Optional lightweight evals run against the user's example prompts. +9. User reviews and approves activation. +10. Runtime saves an immutable definition version and marks the agent active. +11. Agent selector refreshes and the new agent can start runs. + +## Validation + +Every saved agent definition must pass validation. + +Validation should check: + +- Name, id, labels, and descriptions are non-empty and length-bounded. +- Base capability profile is valid. +- Tool policy is a subset of the base profile. +- Approval modes are compatible with the profile. +- Prompt fragments do not contain instruction-hierarchy violations. +- Prompt fragments do not claim unavailable tools. +- Retrieval and memory policies use known record and memory kinds. +- Output contract is clear enough for continuation and handoff. +- External services, commands, browser control, device control, skills, subagents, and destructive writes require explicit user opt-in. +- Secret-shaped text is redacted or rejected. +- Definition version is immutable after activation. + +Validation failures keep the definition in draft and return diagnostics to Agent Create and the UI. + +## Quality Bar + +Agent Create should produce definitions that are closer to built-in descriptors than ad hoc personas. + +Each created agent should have: + +- A narrow purpose. +- A clear workflow. +- A concrete final response contract. +- Explicit tool boundaries. +- A retrieval strategy. +- A memory strategy. +- A safety and approval posture. +- At least three example tasks. +- At least three refusal or escalation cases. +- A validation report. + +The default behavior should be conservative: if the user asks for a broad "do everything" agent, Agent Create should split it into a narrower recommendation or explain the risks before saving. + +## Handoff, Compaction, And Continuity + +Custom agents should participate in the same continuity machinery as built-ins. + +Same-type handoff should mean same agent definition id and pinned version. If a definition was edited after the source run began, the target handoff run should continue with the source run's pinned version unless the user explicitly starts a new run on the latest version. + +Context manifests should include: + +- Agent definition id. +- Agent definition version. +- Base capability profile. +- Prompt fragment hashes. +- Tool policy hash. +- Retrieval query ids. +- Included and excluded memory ids. +- Validation version. + +Auto-compact and auto-handoff should be controlled by the definition's policy and the user's runtime settings. + +## Security + +Agent definitions are untrusted user-authored configuration. + +Security requirements: + +- Prompt text cannot change Xero's instruction hierarchy. +- Tool access is enforced outside the prompt. +- Agent Create cannot save a definition without user approval. +- Agent Create cannot create arbitrary executable tools. +- Custom agents cannot self-escalate tool access during a run. +- Redaction runs before persistence, retrieval display, prompt injection, handoff generation, and validation reports. +- App-data state is the source of truth, not repo-local files. +- Definition exports, if added later, must be explicit and redacted. + +## Implementation Phases + +### Phase 1: Registry Foundation + +Introduce the registry-backed agent descriptor model. Seed Ask, Engineer, Debug, and Agent Create as built-ins. Update frontend schemas, runtime contracts, controls, run snapshots, and tests to accept registry descriptors instead of a closed three-value enum. + +Success condition: the app still supports Ask, Engineer, and Debug, and Agent Create appears as a built-in descriptor without creating agents yet. + +### Phase 2: Persistence And Versioning + +Add app-data-backed storage for agent definitions and immutable versions. Persist selected definition id and version on runs. Update context manifests, handoff lineage, retrieval logs, project records, and usage records to store custom ids and pinned versions. + +Success condition: a custom definition can be inserted by a test, selected for a run, and recovered after reload with the same pinned version. + +### Phase 3: Agent Create Runtime + +Add the `agent_builder` capability profile, Agent Create prompt contract, and controlled agent-definition tools. The tools should draft, validate, save, update, archive, clone, and list definitions. Saving requires explicit user approval. + +Success condition: Agent Create can create a valid observe-only custom agent in a test without repository mutation. + +### Phase 4: Custom Agent Execution + +Compile custom agent prompts from definition fragments. Enforce custom tool policies at registry resolution, tool discovery, tool activation, and dispatch. Integrate custom ids with Lance retrieval, approved memory, memory candidates, project records, handoff, compaction, and run summaries. + +Success condition: a custom engineering-capable agent can inspect, edit, and verify through the same gates as Engineer, while an observe-only custom agent is blocked from mutation like Ask. + +### Phase 5: User-Facing UI + +Add the ShadCN-backed creation and management surfaces. Keep the flow inside the existing runtime experience: select Agent Create, converse, review draft, activate, then select the created agent. + +Success condition: no temporary UI exists, and a user can create, revise, activate, archive, and start a custom agent from normal app surfaces. + +### Phase 6: Quality Evals + +Add scoped eval fixtures for agent definitions. Cover prompt quality, tool-policy narrowing, retrieval behavior, memory candidate behavior, handoff behavior, prompt-injection rejection, and version pinning. + +Success condition: built-ins and representative custom agents pass the same harness quality gates. + +## Test Plan + +Frontend tests: + +- Runtime schemas accept built-ins and custom descriptors. +- Agent selector renders built-ins and active custom agents. +- Agent Create review flow validates draft, blocked, and active states. +- Ask-like custom agents force suggest-only approval. +- Engineering custom agents expose plan and verification controls. + +Rust tests: + +- Fresh schema includes agent definition registry. +- Built-ins seed deterministically. +- Definition versions are immutable. +- Run creation pins definition id and version. +- Missing or archived definitions cannot start new runs. +- Existing pinned runs can load from stored snapshots. +- Tool policy cannot exceed base capability profile. +- Agent Create cannot access repository mutation, command, browser, device, MCP, skill, subagent, or external-service tools. +- Custom observe-only agents cannot mutate. +- Custom engineering agents use normal stale-write, approval, rollback, and verification gates. +- Lance project records and memory retrieval work with custom ids. +- Same-agent handoff preserves definition id and version. +- Redaction blocks unsafe definition content and memory candidates. + +Run focused tests and scoped formatting. Only run one Cargo command at a time. + +## Acceptance Criteria + +- Agent Create exists as a first-class built-in agent. +- A user can create a custom agent through conversation. +- The custom agent is persisted under app-data-backed state, not `.xero/`. +- The custom agent appears in the agent selector. +- The custom agent runs through the owned-agent runtime. +- Tool access is enforced by runtime policy, not prompt text. +- LanceDB-backed project records, approved memory, retrieval logs, context manifests, memory candidates, and handoff all include custom agent metadata. +- Agent definitions are versioned and pinned per run. +- Unsafe definitions are blocked before activation. +- Scoped frontend and Rust tests cover the core behavior. + +## Open Questions + +- Should global custom agents be available to every project immediately, or should each project opt in? +- Should Agent Create allow users to import and export agent definitions in v1? +- Should custom agents support subagents in v1, or should that be reserved for built-ins until policy is stronger? +- Should model/provider defaults be part of the definition, or only a suggestion applied to runtime controls? +- Should a saved custom agent be activated immediately after approval, or should the user explicitly click an activation control after validation? + +## Recommended V1 Decisions + +- Support both global and project-scoped agents. +- Do not support import/export yet. +- Do not let Agent Create create new tools. +- Disable subagent access for custom agents by default. +- Store provider/model defaults as suggestions, not hard requirements. +- Activate immediately after explicit approval and successful validation. + diff --git a/AGENT_RUNTIME_CONVERSATION_FIX_PLAN.md b/AGENT_RUNTIME_CONVERSATION_FIX_PLAN.md new file mode 100644 index 00000000..e165bbec --- /dev/null +++ b/AGENT_RUNTIME_CONVERSATION_FIX_PLAN.md @@ -0,0 +1,261 @@ +# Agent Runtime Conversation Fix Plan + +## Reader And Goal + +This plan is for the engineer fixing the Agent tab conversation experience. After reading it, they should be able to implement the fixes for broken streaming text, generic tool activity cards, top-biased message placement, and unstable scrolling without adding temporary debug UI. + +## Problem Statement + +The screenshots from May 2, 2026 show four related user-facing failures: + +1. The user's first prompt appears near the top of an otherwise empty conversation instead of feeling anchored to the composer/latest message area. +2. The agent rapidly emits many tool cards, but each card only shows state plus the tool name and usually says `Tool activity recorded.` It does not show which path/pattern/command was inspected or what the result was. +3. While the assistant response streams, the visible area appears to chase only a couple of text rows at a time. +4. The assistant response formatting is corrupted. Examples visible in the screenshots include split words such as `mon om orph ization`, split command/code text such as `` `mesh c build` ``, and broken markdown such as `Native binary **`. + +These failures have separate causes but compound into one broken experience. + +## Root Cause Summary + +The highest-confidence root cause is in the client-side transcript joiner. Provider deltas are already emitted and persisted as exact string deltas, but the Agent UI reconstructs adjacent transcript items by inserting spaces between chunks. That mutates streamed text and breaks words, markdown markers, and inline code. + +The generic tool card problem is a runtime projection/rendering gap. Owned-agent tool events contain useful `input`, `summary`, and `output` payloads, but the Tauri stream projection does not place those values into the fields the React tool card reads. + +The top placement and rapid scroll behavior come from layout and scroll-state gaps. The Agent pane has an overflow container, but no scroll anchor, no "stick to bottom unless the user scrolled away" behavior, and no bottom-aligned empty/small conversation layout. + +## Evidence And Proof + +### 1. Streamed text is corrupted by the React joiner + +`client/components/xero/agent-runtime.tsx` defines `appendTranscriptDelta`: + +```ts +if (/\s$/.test(current) || /^\s/.test(delta) || /^[.,!?;:%)\]}]/.test(delta)) { + return `${current}${delta}` +} + +return `${current} ${delta}` +``` + +This runs inside `buildConversationTurns` when adjacent stream transcript items have the same role. It assumes deltas are word tokens. Provider deltas are not word tokens; they can split anywhere inside a word or markdown marker. + +Backend/provider evidence: + +- `client/src-tauri/src/runtime/agent_core/provider_adapters.rs` appends OpenAI chat deltas with `message.push_str(&content)` and emits the exact `content`. +- The Responses parser also uses `message.push_str(&delta)` and emits the exact `delta`. +- `client/src-tauri/src/runtime/agent_core/provider_loop.rs` persists provider deltas as `AgentRunEventKind::MessageDelta` with `{ "role": "assistant", "text": text }`. +- `client/src-tauri/src/commands/subscribe_runtime_stream.rs` projects those event payloads directly into runtime stream transcript item `text`. + +Local reproduction of the current joiner: + +```text +["**Native binary","** Main modules"] => "**Native binary ** Main modules" +["mon","om","orph","ization"] => "mon om orph ization" +["`mesh","c","build`"] => "`mesh c build`" +["crate","-by","-crate"] => "crate -by -crate" +``` + +That matches the visible screenshot artifacts closely enough to treat this as confirmed. + +### 2. Existing tests encode an incomplete streaming model + +`client/components/xero/agent-runtime.test.tsx` has a test that expects chunks `Hi`, `!`, `What`, `can`, `I` to render as `Hi! What can I`. That test covers word-like chunks, not arbitrary provider deltas. It does not cover subword chunks, markdown delimiters split across chunks, or inline code split across chunks. + +### 3. Tool cards discard the useful owned-agent event payloads + +`client/src-tauri/src/runtime/agent_core/tool_dispatch.rs` records useful tool event payloads: + +- `ToolStarted` includes `toolCallId`, `toolName`, and redacted `input`. +- `ToolCompleted` includes `toolCallId`, `toolName`, `ok`, `summary`, and full structured `output`. + +`client/src-tauri/src/commands/subscribe_runtime_stream.rs` projects tool events, but for owned-agent tools it only sets: + +- `tool_call_id` +- `tool_name` +- `tool_state` +- `text` for started/completed summaries + +It does not set `detail`, and it does not set `tool_summary`. + +The React side ignores `text` for tool cards: + +- `normalizeRuntimeStreamItem` in `client/src/lib/xero-model/runtime-stream.ts` maps tool items to `detail: normalizeOptionalText(event.item.detail)` and `toolSummary`. +- `actionTurnFromItem` in `client/components/xero/agent-runtime.tsx` renders `item.detail ?? summary ?? 'Tool activity recorded.'`. +- `getToolSummaryContext` currently only formats `mcp_capability` and `browser_computer_use` summaries, even though the shared schema has `command`, `file`, `git`, and `web` summary variants. + +Therefore an owned-agent `read`, `find`, or `list` can contain useful data in the backend event and still render as only `RUNNING list` / `SUCCEEDED list` / `Tool activity recorded.` + +### 4. Activity events are hidden from the main conversation + +The frontend asks for runtime stream kinds including `activity`, but `buildConversationTurns` only admits `transcript`, `tool`, and `failure`. Tool argument deltas, command output summaries, file-change summaries, validation, plan updates, and policy decisions are filtered out of the visible conversation. + +Some activity is too noisy for the main chat, so filtering is reasonable. The problem is that there is no replacement disclosure that lets the user inspect the tool call's input/result at the card level. + +### 5. The conversation is top-biased and has no scroll owner + +In `client/components/xero/agent-runtime.tsx`, the message viewport is: + +```tsx +
+
+ +
+
+``` + +There is no scroll container ref, no bottom sentinel, no `scrollIntoView`, no near-bottom tracking, and no jump-to-latest affordance. With a short conversation, content naturally starts at the top. With streaming content and many tool cards, browser scroll anchoring/layout updates can make the viewport feel like it is rapidly chasing the active rows. + +### 6. Feed caps add churn + +The client keeps only recent stream items: + +- `MAX_RUNTIME_STREAM_ITEMS = 40` in `client/src/lib/xero-model/runtime-stream.ts` +- `MAX_VISIBLE_RUNTIME_FEED_ITEMS = 24` in `client/components/xero/agent-runtime.tsx` + +This is fine for memory, but combined with one card per tool state transition it makes long tool bursts feel like a constantly rotating list. It also risks dropping the user's initial prompt from the visible feed during high-volume activity. + +## Fix Plan + +### Phase 1: Preserve transcript bytes exactly + +Replace the spacing joiner for streamed assistant transcript chunks with exact concatenation. + +Recommended behavior: + +- Assistant transcript deltas from the same run should concatenate as `current + delta`. +- User transcript items should normally render as distinct user messages, because they are submitted as full prompts rather than provider-style deltas. +- If a future stream contract needs explicit message grouping, add a `messageId`/`turnId` to the stream item instead of guessing from role adjacency. + +Implementation targets: + +- `client/components/xero/agent-runtime.tsx` +- `client/components/xero/agent-runtime.test.tsx` + +Tests to add/update: + +- Chunks `["mon", "om", "orph", "ization"]` render as `monomorphization`. +- Chunks `["**Native binary", "** Main modules"]` preserve valid bold marker placement. +- Chunks ``["`mesh", "c", " build`"]`` render without inserted spaces. +- Consecutive full user prompts do not silently merge into one bubble. + +### Phase 2: Project owned-agent tool details into the runtime stream + +Populate the fields the UI already reads. + +Recommended projection changes in `client/src-tauri/src/commands/subscribe_runtime_stream.rs`: + +- For `ToolStarted`, set `detail` from the redacted `input` in a concise form. Examples: `path: client/components/xero/agent-runtime.tsx`, `pattern: appendTranscriptDelta`, `cwd: /repo, cmd: rg ...`. +- For `ToolCompleted`, set `detail` from `summary` or `message`. +- For successful tool completions, derive `tool_summary` from structured `output` when possible using the existing `ToolResultSummaryDto` variants: `command`, `file`, `git`, `web`, `browser_computer_use`, `mcp_capability`. +- For failures, set `detail` to the diagnostic message and keep `code`/`message`. + +Tests to add/update: + +- Rust unit tests in `subscribe_runtime_stream.rs` proving `ToolStarted` carries a redacted useful detail. +- Rust unit tests proving `ToolCompleted` maps `summary` into `detail`. +- Rust unit tests proving at least file/read, search/find, command, and git summaries map into `tool_summary`. + +### Phase 3: Render useful, compact tool cards + +Update the React tool card layer so the user can understand what happened without opening devtools. + +Implementation targets: + +- `client/components/xero/agent-runtime/conversation-section.tsx` +- `client/components/xero/agent-runtime/runtime-stream-helpers.ts` +- `client/components/xero/agent-runtime.test.tsx` +- `client/components/xero/agent-runtime/helpers.test.ts` + +Recommended UI: + +- Collapse duplicate started/completed events for the same `toolCallId` into one card that updates state. +- Show primary label as action plus target, not only tool name. Examples: `read agent-runtime.tsx`, `find appendTranscriptDelta`, `list client/components/xero`. +- Show one-line outcome detail by default. +- Add a ShadCN `Collapsible` or `Accordion` section for sanitized details/output summaries. This is user-facing inspection UI, not temporary debug UI. +- Continue to avoid dumping raw full stdout or raw file contents into the main feed. + +Helper coverage to add: + +- `getToolSummaryContext` should format `command`, `file`, `git`, and `web` summary variants, not only MCP/browser summaries. +- Long paths and commands should truncate visually but remain available through title/tooltip/copy affordances if already present in local UI patterns. + +### Phase 4: Add a real conversation scroll model + +Give the Agent pane explicit ownership of scroll behavior. + +Implementation target: + +- `client/components/xero/agent-runtime.tsx` + +Recommended behavior: + +- Keep a ref to the scroll viewport and a bottom sentinel. +- Track whether the user is near the bottom. +- On new user submission, scroll to bottom. +- While streaming and still near bottom, keep the bottom sentinel visible with `behavior: 'auto'` or a throttled non-animated scroll. +- If the user scrolls upward, stop auto-following and show a small user-facing "jump to latest" button. +- For short conversations, use a `min-h-full` inner layout with `justify-end` so the latest prompt starts near the composer instead of pinned under the breadcrumb. +- Add enough bottom padding so the composer gradient never visually occludes the final rows. + +Tests: + +- Use unit tests around the near-bottom helper math if extracted. +- Add a component test that dispatches scroll events and asserts auto-follow is disabled when the user scrolls away. +- Use Tauri/e2e visual verification if the repo has a Tauri harness. Do not open the app in a normal browser. + +### Phase 5: Keep or replace the markdown renderer based on evidence + +The immediate markdown corruption should disappear after exact delta concatenation. After Phase 1, retest with streamed markdown: + +- headings +- bullets +- inline code +- bold text +- fenced code + +If failures remain, replace the custom renderer in `client/components/xero/agent-runtime/conversation-markdown.tsx` with a small CommonMark-compatible renderer. If keeping the custom renderer, add tests for split markdown tokens so this regression cannot return. + +### Phase 6: Reduce feed churn + +After tool details render correctly, reduce visual churn: + +- Dedupe tool state transitions by `toolCallId` before building visible turns. +- Keep transcript turns independent from the activity cap so a burst of tool calls cannot evict the user's prompt or the active assistant reply. +- Consider a compact "N tool calls" grouped activity section when many tools run in sequence. + +## Verification Plan + +Run scoped checks only. + +Frontend: + +```bash +pnpm --dir client test --run client/components/xero/agent-runtime.test.tsx client/components/xero/agent-runtime/helpers.test.ts +pnpm --dir client test --run client/src/lib/xero-model/runtime-stream.test.ts +``` + +Rust: + +```bash +cd client/src-tauri +cargo test subscribe_runtime_stream +``` + +Only run one Cargo command at a time. + +Manual/Tauri verification: + +1. Start a real Agent session in the Tauri app. +2. Ask for a codebase walkthrough. +3. Confirm streamed markdown no longer splits words or code spans. +4. Confirm tool cards show paths/patterns/results. +5. Confirm the latest content remains readable while streaming. +6. Scroll upward during streaming and confirm the app does not force-scroll until the user jumps to latest. + +## Risks And Notes + +- The current test that expects `Hi`, `!`, `What`, `can`, `I` to render as `Hi! What can I` will need to change; it encodes an unrealistic provider-delta model. +- Exact concatenation may reveal that some non-provider transcript events need explicit separators. If that happens, fix the stream contract with message grouping metadata rather than reintroducing heuristic spacing. +- Tool result summaries must remain sanitized. Do not put raw file contents, secrets, or giant stdout into always-visible cards. +- The runtime already has summary DTOs for command/file/git/web. Prefer using those existing contracts before adding new UI-only shapes. +- This is a new application, so prefer correcting the contract and tests over maintaining compatibility with the broken spacing behavior. diff --git a/PLATFORM_SUPPORT_AUDIT_AND_PLAN.md b/PLATFORM_SUPPORT_AUDIT_AND_PLAN.md deleted file mode 100644 index 790de8e4..00000000 --- a/PLATFORM_SUPPORT_AUDIT_AND_PLAN.md +++ /dev/null @@ -1,82 +0,0 @@ -# Platform Support Audit And Plan - -Date: 2026-04-30 -Implementation status updated: 2026-05-01 - -Goal: make every feasible Xero desktop feature work on macOS, Windows, and Linux. Platform-only features are allowed when the host really requires them, such as iOS Simulator, macOS dictation/TCC prompts, and macOS window automation. - -## Audit Scope - -Reviewed: - -- Tauri desktop config and capabilities: `client/src-tauri/tauri.conf.json`, `client/src-tauri/capabilities/*.json` -- Rust app-data and registry storage: `client/src-tauri/src/state.rs`, `client/src-tauri/src/global_db`, `client/src-tauri/src/db` -- Process/runtime tools: `client/src-tauri/src/runtime/platform_adapter.rs`, `client/src-tauri/src/runtime/process_tree.rs`, `client/src-tauri/src/runtime/autonomous_tool_runtime` -- Mobile emulator support: `client/src-tauri/src/commands/emulator` -- Solana workbench toolchains and state: `client/src-tauri/src/commands/solana` -- Browser cookie sidecar: `client/src-tauri/src/commands/browser/cookie_import.rs`, `client/src-tauri/crates/cookie-importer` -- Dev scripts: `scripts/dev-preflight.mjs`, `client/scripts/*.mjs` -- Frontend platform gates and user-facing path copy: `client/components/xero` - -## Implementation Complete - -- Changed Tauri bundle targets from macOS-only `["app"]` to `"all"` so Windows and Linux package targets are not excluded at config level. -- Made autonomous command sanitized `PATH` fallback platform-aware instead of injecting a Unix path on Windows. -- Made Solana fallback binary search paths platform-aware: - - Windows: `USERPROFILE`-based Solana/Cargo/AVM paths and `APPDATA/npm`. - - macOS: Homebrew and system binary locations. - - Linux: standard Unix binary locations. -- Made `scripts/dev-preflight.mjs` use Windows shell spawning for `.cmd`/`.bat` tools and attempt to launch Docker Desktop on Windows. -- Replaced macOS-looking UI placeholders such as `/Users/you/...` and `~/.config/...` with neutral, cross-platform path prompts. -- Replaced the stale desktop matrix with runnable commands in `client/src-tauri/tests/platform-matrix.md`. -- Added Windows system process, listening-port, process-exists, and external signal support in the autonomous process manager, with parser fixtures for PowerShell and legacy command output. -- Removed host CLI archive assumptions from the Tauri build script and Android provisioning by using Rust download/extraction libraries for `.zip` and `.tar.gz` archives. -- Split macOS-only `idb-companion.universal` resources into `tauri.macos.conf.json`; the base config no longer requires the iOS sidecar tree for Windows/Linux packaging. -- Threaded Tauri `app_data_dir()` into `SolanaState` so snapshots, personas, Metaplex worker cache, and program archives share one app-owned Solana state root. -- Added a backend `desktop_platform` Tauri command and taught the shell to prefer it over user-agent detection, with the old user-agent path retained for tests and non-Tauri rendering. -- Expanded Rust and TypeScript path redaction coverage for common Windows app-data and temp locations. -- Replaced Rust `/tmp/...` test fixtures in the audited platform-sensitive paths with `temp_dir()`/`TempDir` derived paths. - -## Current Platform Matrix - -| Area | macOS | Windows | Linux | Notes | -| --- | --- | --- | --- | --- | -| Tauri app boot | Expected | Expected | Expected | Base config targets all platforms; macOS-only sidecars live in the macOS overlay. | -| App state and global DB | Expected | Expected | Expected | Main registry uses `app.path().app_data_dir()` and avoids repo-local `.xero`. | -| Per-project state DB | Expected | Expected | Expected | Stored under app-data derived project directories. Unix chmod hardening is no-op on Windows by design. | -| Frontend shell chrome | Expected | Expected | Expected | UI has macOS, Windows, and Linux variants, initialized from the backend platform command in desktop mode. | -| iOS Simulator | Expected | Unsupported by design | Unsupported by design | Correctly cfg-gated and hidden outside macOS. | -| Native dictation | Expected | Unsupported by design today | Unsupported by design today | Current command returns typed unsupported diagnostics outside macOS. | -| Android emulator | Expected | Expected with SDK/JDK prerequisites | Expected with SDK/JDK/KVM prerequisites | Provisioning supports all three and uses Rust archive extraction. | -| Browser cookie import | Expected | Expected | Expected | Sidecar builds per host. Safari is macOS-only by cfg. | -| Solana toolchain probe/install | Expected | Expected on x64 | Expected on x64 | Managed Agave/Anchor installers cover macOS plus Windows/Linux x64. Linux arm64 remains unsupported by that upstream artifact set. | -| Autonomous command/process sessions | Expected | Expected | Expected | Owned process spawning is platform-aware. | -| Autonomous system process/port inspection | Expected | Expected | Expected | Windows uses PowerShell first with `tasklist`, `netstat`, and `taskkill` fallbacks. | -| macOS automation tool | Expected | Unsupported by design | Unsupported by design | Correctly returns typed unavailable result outside macOS. | -| Dev preflight | Expected | Expected | Expected | Windows Docker Desktop launch added; Mix and Docker command spawning uses shell on Windows. | - -## Native Host Verification Gate - -Implementation work is complete in this branch. Release validation still needs the matrix in `client/src-tauri/tests/platform-matrix.md` run on actual desktop hosts: - -- macOS arm64 and x64 where available. -- Windows x64. -- Linux x64. - -Per-host commands: - -- `pnpm --dir client test` -- `cargo test --manifest-path client/src-tauri/Cargo.toml` -- `cargo check --manifest-path client/src-tauri/Cargo.toml` -- `pnpm --dir client exec tauri build --debug` - -Keep Cargo commands serialized so the Cargo lock is never contended. - -## Acceptance Criteria - -- A fresh clone can build the Tauri app on macOS, Windows, and Linux. -- Core project import, file browsing/editing, provider settings, browser UI, notifications config, and agent runtime work on all three desktop OSes. -- Unsupported features return typed, user-visible unsupported results and are hidden or disabled in UI where appropriate. -- No new app state is written to repo-local `.xero/`. -- No hardcoded user home paths appear in production UI or runtime behavior. -- Platform-specific code is guarded with `cfg` or runtime platform checks, and tests cover both supported and unsupported branches. diff --git a/README.md b/README.md index 5c4d5c37..73198d08 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,10 @@ OpenAI-compatible setup recipes cover LiteLLM, LM Studio, Mistral, Groq, Togethe Xero supports session transcript search, Markdown/JSON export, context visualization, manual compact, opt-in auto-compact, reviewed memory, branch, and rewind workflows. See `docs/session-memory-and-context.md` for the user workflow, privacy guarantees, and support triage guidance. +## Agent Harness Benchmarking + +Xero's owned-agent harness should be compared with fixed-model, sandboxed benchmark runs rather than informal leaderboard screenshots. See `docs/agent-harness-benchmarking.md` for the research summary, benchmark choices, and implementation plan. + ## Skills And Plugins Xero discovers static and dynamic project skills/plugins and stores trusted project artifacts in app data, not inside the imported repository. See `docs/skills-and-plugins.md` for authoring, trust, and runtime notes. @@ -440,4 +444,4 @@ If you’re new to this repo, start here: ## Current Status Summary -This repository is actively structured around a Tauri desktop runtime with broad command surfaces for runtime orchestration, browser automation, mobile emulator control, Solana workflows, skills/plugins, and session memory. The `server/` Phoenix app and local Postgres service support web callback/shared backend features. The `landing/` app is a separate Next.js site used alongside (not instead of) the desktop host. \ No newline at end of file +This repository is actively structured around a Tauri desktop runtime with broad command surfaces for runtime orchestration, browser automation, mobile emulator control, Solana workflows, skills/plugins, and session memory. The `server/` Phoenix app and local Postgres service support web callback/shared backend features. The `landing/` app is a separate Next.js site used alongside (not instead of) the desktop host. diff --git a/client/components/xero/agent-runtime.test.tsx b/client/components/xero/agent-runtime.test.tsx index 26ea2b98..9d670220 100644 --- a/client/components/xero/agent-runtime.test.tsx +++ b/client/components/xero/agent-runtime.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' afterEach(() => { @@ -17,7 +17,10 @@ if (!HTMLElement.prototype.releasePointerCapture) { HTMLElement.prototype.releasePointerCapture = () => {} } -import { AgentRuntime } from '@/components/xero/agent-runtime' +import { + AgentRuntime, + isRuntimeConversationNearBottom, +} from '@/components/xero/agent-runtime' import type { SpeechDictationAdapter } from '@/components/xero/agent-runtime/use-speech-dictation' import type { AgentPaneView } from '@/src/features/xero/use-xero-desktop-state' import type { DictationEventDto, DictationStatusDto } from '@/src/lib/xero-model/dictation' @@ -25,10 +28,9 @@ import type { ProjectDetailView, RuntimeRunView, RuntimeSessionView, + RuntimeStreamToolItemView, } from '@/src/lib/xero-model' -type CheckpointControlLoopCard = NonNullable['items'][number] - function makeProject(overrides: Partial = {}): ProjectDetailView { return { id: 'project-1', @@ -136,6 +138,8 @@ function makeRuntimeRun(overrides: Partial = {}): RuntimeRunView controls: { active: { providerProfileId: null, + agentDefinitionId: null, + agentDefinitionVersion: null, runtimeAgentId: 'ask', runtimeAgentLabel: 'Ask', modelId: 'openai_codex', @@ -151,6 +155,8 @@ function makeRuntimeRun(overrides: Partial = {}): RuntimeRunView selected: { source: 'active', providerProfileId: null, + agentDefinitionId: null, + agentDefinitionVersion: null, runtimeAgentId: 'ask', runtimeAgentLabel: 'Ask', modelId: 'openai_codex', @@ -248,134 +254,6 @@ function makeProviderModelCatalog( } } -function makeCheckpointControlLoopCard( - overrides: Partial = {}, -): CheckpointControlLoopCard { - const approval = overrides.approval ?? { - actionId: 'flow:flow-1:run:run-1:boundary:boundary-1:terminal_input_required', - sessionId: 'session-1', - flowId: 'flow-1', - actionType: 'terminal_input_required', - title: 'Terminal input required', - detail: 'Provide terminal input before the run can continue.', - userAnswer: 'Looks good to resume.', - status: 'approved' as const, - statusLabel: 'Approved', - decisionNote: 'Ready to resume.', - createdAt: '2026-04-16T20:03:00Z', - updatedAt: '2026-04-16T20:03:30Z', - resolvedAt: '2026-04-16T20:03:30Z', - isPending: false, - isResolved: true, - canResume: true, - isRuntimeResumable: true, - requiresUserAnswer: true, - answerRequirementReason: 'runtime_resumable' as const, - answerRequirementLabel: 'Required', - answerShapeKind: 'plain_text' as const, - answerShapeLabel: 'Required user answer', - answerShapeHint: 'Describe the operator decision that justifies approval.', - answerPlaceholder: 'Provide operator input for this action.', - } - - return { - key: 'flow:flow-1:run:run-1:boundary:boundary-1:terminal_input_required::boundary-1', - actionId: approval.actionId, - boundaryId: 'boundary-1', - title: approval.title, - detail: approval.detail, - truthSource: 'durable_only', - truthSourceLabel: 'Durable only', - truthSourceDetail: 'The live row has cleared or is unavailable, so this card is anchored to durable approval and resume truth.', - liveActionRequired: null, - liveStateLabel: 'Live row unavailable', - liveStateDetail: - 'The selected project snapshot still shows this checkpoint as pending even though the live stream no longer has a matching row.', - liveUpdatedAt: '2026-04-16T20:03:30Z', - approval, - durableStateLabel: approval.statusLabel, - durableStateDetail: approval.detail, - durableUpdatedAt: approval.updatedAt, - latestResume: { - id: 1, - sourceActionId: approval.actionId, - sessionId: 'session-1', - status: 'started', - statusLabel: 'Resume started', - summary: 'Operator resumed the selected project runtime session.', - createdAt: '2026-04-16T20:04:00Z', - }, - resumeStateLabel: 'Resume started', - resumeDetail: 'Operator resumed the selected project runtime session.', - resumeUpdatedAt: '2026-04-16T20:04:00Z', - resumability: 'resumable', - resumabilityLabel: 'Resumable', - resumabilityDetail: - 'Xero has a durable resume path for this action using the existing approve/reject/resume controls.', - isResumable: true, - advancedFailureClass: null, - advancedFailureClassLabel: null, - advancedFailureDiagnosticCode: null, - recoveryRecommendation: 'approve_resume', - recoveryRecommendationLabel: 'Approve / resume', - recoveryRecommendationDetail: - 'Use the existing approve/reject/resume controls for this action. Xero will refresh durable truth after the decision is persisted.', - brokerAction: { - actionId: approval.actionId, - dispatches: [], - dispatchCount: 0, - pendingCount: 0, - sentCount: 0, - failedCount: 0, - claimedCount: 0, - latestUpdatedAt: null, - hasFailures: false, - hasPending: false, - hasClaimed: false, - }, - brokerStateLabel: 'Broker diagnostics unavailable', - brokerStateDetail: 'No notification broker fan-out rows were retained for this action in the bounded dispatch window.', - brokerLatestUpdatedAt: null, - brokerRoutePreviews: [], - evidenceCount: 1, - evidenceStateLabel: '1 durable evidence row', - evidenceSummary: 'Showing the latest durable evidence row linked to this action.', - latestEvidenceAt: '2026-04-16T20:04:10Z', - evidencePreviews: [ - { - artifactId: 'artifact-checkpoint-1', - artifactKindLabel: 'Verification evidence', - statusLabel: 'Recorded', - summary: 'Captured resume verification evidence for this action.', - updatedAt: '2026-04-16T20:04:10Z', - }, - ], - sortTimestamp: '2026-04-16T20:04:10Z', - ...overrides, - } -} - -function makeCheckpointControlLoop( - overrides: Partial> = {}, -): NonNullable { - return { - items: [makeCheckpointControlLoopCard()], - totalCount: 1, - visibleCount: 1, - hiddenCount: 0, - isTruncated: false, - windowLabel: 'Showing 1 checkpoint action from the bounded control-loop window.', - emptyTitle: 'No checkpoint control loops recorded', - emptyBody: - 'Xero has not observed a live or durable checkpoint boundary for this project yet. Waiting boundaries, resume outcomes, and broker fan-out will appear here once recorded.', - missingEvidenceCount: 0, - liveHintOnlyCount: 0, - durableOnlyCount: 1, - recoveredCount: 0, - ...overrides, - } -} - function makeAgent(overrides: Partial = {}): AgentPaneView { const project = overrides.project ?? makeProject() const runtimeSession = overrides.runtimeSession ?? null @@ -486,7 +364,7 @@ function makeAgent(overrides: Partial = {}): AgentPaneView { trustSnapshot: undefined, sessionUnavailableReason: overrides.sessionUnavailableReason ?? 'Current session status for this project.', runtimeRunUnavailableReason: - overrides.runtimeRunUnavailableReason ?? 'Xero recovered a Xero-owned agent run and its durable checkpoints before the live runtime feed resumed.', + overrides.runtimeRunUnavailableReason ?? 'Xero recovered a Xero-owned agent run before the live runtime feed resumed.', messagesUnavailableReason: overrides.messagesUnavailableReason ?? 'Xero authenticated this project, but the live runtime stream has not started yet.', ...overrides, @@ -571,7 +449,120 @@ function createDictationAdapter(options: { } } +function makeTranscriptItem(options: { + sequence: number + role?: 'user' | 'assistant' + text: string +}) { + return { + id: `transcript:run-1:${options.sequence}`, + kind: 'transcript' as const, + runId: 'run-1', + sequence: options.sequence, + createdAt: `2026-04-29T00:48:${String(options.sequence).padStart(2, '0')}Z`, + role: options.role ?? 'assistant', + text: options.text, + } +} + +function makeToolItem( + options: Partial & { + sequence: number + toolCallId: string + toolName: string + toolState: RuntimeStreamToolItemView['toolState'] + }, +): RuntimeStreamToolItemView { + const { sequence, ...rest } = options + + return { + id: `tool:run-1:${sequence}`, + kind: 'tool', + runId: 'run-1', + sequence, + createdAt: `2026-04-29T00:48:${String(sequence).padStart(2, '0')}Z`, + detail: null, + toolSummary: null, + ...rest, + } +} + +function makeReasoningItem(options: { + sequence: number + text: string +}) { + const detail = options.text.trim() || 'Owned agent reasoning summary updated.' + return { + id: `activity:run-1:${options.sequence}`, + kind: 'activity' as const, + runId: 'run-1', + sequence: options.sequence, + createdAt: `2026-04-29T00:48:${String(options.sequence).padStart(2, '0')}Z`, + code: 'owned_agent_reasoning', + title: 'Reasoning', + text: options.text, + detail, + } +} + +function setScrollMetrics( + element: HTMLElement, + metrics: { scrollTop: number; scrollHeight: number; clientHeight: number }, +) { + Object.defineProperty(element, 'scrollTop', { + configurable: true, + writable: true, + value: metrics.scrollTop, + }) + Object.defineProperty(element, 'scrollHeight', { + configurable: true, + value: metrics.scrollHeight, + }) + Object.defineProperty(element, 'clientHeight', { + configurable: true, + value: metrics.clientHeight, + }) +} + +function renderRuntimeStreamItems(runtimeStreamItems: NonNullable) { + return render( + , + ) +} + describe('AgentRuntime current UI', () => { + it('classifies conversation scroll positions near the bottom', () => { + expect( + isRuntimeConversationNearBottom({ + scrollTop: 500, + scrollHeight: 1_000, + clientHeight: 420, + }), + ).toBe(true) + expect( + isRuntimeConversationNearBottom({ + scrollTop: 300, + scrollHeight: 1_000, + clientHeight: 420, + }), + ).toBe(false) + expect( + isRuntimeConversationNearBottom({ + scrollTop: 0, + scrollHeight: 320, + clientHeight: 420, + }), + ).toBe(true) + }) + it('hides the autonomous ledger and remote-escalation debug panels', () => { render( { }) - it('renders checkpoint control-loop cards and resume controls on the Agent tab', () => { - render( - , - ) - - expect(screen.getByRole('heading', { name: 'Checkpoint control loop' })).toBeVisible() - expect(screen.getByText('Review worktree changes')).toBeVisible() - expect(screen.getByRole('button', { name: 'Resume run' })).toBeVisible() - }) - it('does not render worker lifecycle cards on the Agent tab', () => { render( { ).not.toBeInTheDocument() }) + it('keeps memory management hidden for the selected agent session', () => { + render( + , + ) + + expect(screen.queryByText('Memory')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Extract' })).not.toBeInTheDocument() + }) + it('surfaces failed run diagnostics and starts a replacement run from the composer', async () => { const onStartRuntimeRun = vi.fn(async () => makeRuntimeRun({ runId: 'run-2' })) @@ -734,6 +708,7 @@ describe('AgentRuntime current UI', () => { controls: { providerProfileId: 'unscoped', runtimeAgentId: 'ask', + agentDefinitionId: null, modelId: 'openai_codex', thinkingEffort: null, approvalMode: 'suggest', @@ -742,6 +717,72 @@ describe('AgentRuntime current UI', () => { prompt: '1+1', }), ) + await waitFor(() => expect(input).toHaveValue('')) + }) + + it('shows a handoff notice when the runtime stream completion reports a same-type handoff', () => { + render( + , + ) + + expect(screen.getByText('Run continued in a fresh session')).toBeVisible() + expect( + screen.getByText(/handed this conversation off to a new same-type run/i), + ).toBeVisible() + expect(screen.queryByText('Latest saved run failed')).not.toBeInTheDocument() }) it('renders runtime stream messages as chronological conversation turns', () => { @@ -793,359 +834,320 @@ describe('AgentRuntime current UI', () => { expect(screen.queryByText('Validation started')).not.toBeInTheDocument() }) - it('groups streamed assistant transcript deltas into one response', () => { + it('shows an agent thinking row immediately while a submitted prompt is starting', () => { render( , ) - expect(screen.getByText('Hi! What can I')).toBeVisible() - expect(screen.getAllByText('Agent')).toHaveLength(1) + expect(screen.getByRole('status', { name: 'Agent is thinking' })).toBeVisible() + expect(screen.getByText('Thinking')).toBeVisible() + expect(screen.queryByText(/What can we build together/i)).not.toBeInTheDocument() }) - it('renders recovered durable denial cards on the Agent tab', () => { - const deniedActionId = 'flow:flow-1:run:run-1:boundary:boundary-denied-1:review_command' - const deniedCard = makeCheckpointControlLoopCard({ - actionId: deniedActionId, - key: `${deniedActionId}::boundary-denied-1`, - boundaryId: 'boundary-denied-1', - title: 'Xero denied the autonomous shell command because its cwd escapes the imported repository root.', - detail: 'Xero denied the autonomous shell command because its cwd escapes the imported repository root.', - truthSource: 'recovered_durable', - truthSourceLabel: 'Recovered durable denial', - truthSourceDetail: - 'No resumable live review row remains, so this card is anchored to the durable shell-policy denial that Xero persisted for the command.', - liveActionRequired: null, - liveStateLabel: 'No live review row', - liveStateDetail: - 'Hard-denied shell-policy outcomes do not create a resumable live action-required row, so Xero is anchoring this card to durable denial evidence.', - liveUpdatedAt: null, - approval: null, - durableStateLabel: 'Policy denied', - durableStateDetail: 'Xero denied the autonomous shell command because its cwd escapes the imported repository root.', - durableUpdatedAt: '2026-04-16T20:04:10Z', - latestResume: null, - resumeStateLabel: 'Not resumable', - resumeDetail: 'Hard-denied shell-policy outcomes do not create an operator approval or resume path.', - resumeUpdatedAt: '2026-04-16T20:04:10Z', - resumability: 'not_resumable', - resumabilityLabel: 'Not resumable', - resumabilityDetail: - 'Xero recorded a hard denial for this action, so no operator resume path is available for this boundary.', - isResumable: false, - advancedFailureClass: null, - advancedFailureClassLabel: null, - advancedFailureDiagnosticCode: null, - recoveryRecommendation: 'fix_permissions_policy', - recoveryRecommendationLabel: 'Fix permissions / policy', - recoveryRecommendationDetail: - 'Browser/computer-use action was blocked by policy or permissions. Fix access or policy before retrying.', - evidenceCount: 2, - evidenceStateLabel: '2 durable evidence rows', - evidenceSummary: 'Showing the latest durable evidence rows linked to this action.', - latestEvidenceAt: '2026-04-16T20:04:10Z', - evidencePreviews: [ - { - artifactId: 'artifact-policy-denied', - artifactKindLabel: 'Policy denied', - statusLabel: 'Recorded', - summary: 'Xero denied the autonomous shell command because its cwd escapes the imported repository root.', - updatedAt: '2026-04-16T20:04:10Z', - }, - ], - }) + it('pauses auto-follow when the user scrolls away and resumes from the latest button', () => { + const scrollIntoView = vi.mocked(HTMLElement.prototype.scrollIntoView) + const initialItems: NonNullable = [ + makeTranscriptItem({ sequence: 2, role: 'user', text: 'Walk me through the runtime.' }), + makeTranscriptItem({ sequence: 3, text: 'Working' }), + ] - render( + const { rerender } = render( , ) - expect(screen.getByRole('heading', { name: 'Checkpoint control loop' })).toBeVisible() - expect(screen.getByText('Recovered durable denial')).toBeVisible() - expect(screen.getAllByText('Policy denied').length).toBeGreaterThan(0) - expect(screen.getByText(/Recovery guidance Fix permissions \/ policy/i)).toBeVisible() - }) - - it('surfaces operator-answer controls and operator-action failures on the Agent tab', () => { - const pendingCard = makeCheckpointControlLoopCard({ - actionId: 'action-pending', - key: 'action-pending::boundary-1', - boundaryId: 'boundary-1', - title: 'Review worktree changes', - detail: 'Inspect the repository diff before trusting the next operator step.', - approval: { - actionId: 'action-pending', - sessionId: 'session-1', - flowId: 'flow-1', - actionType: 'review_worktree', - title: 'Review worktree changes', - detail: 'Inspect the repository diff before trusting the next operator step.', - userAnswer: null, - status: 'pending', - statusLabel: 'Pending approval', - decisionNote: null, - createdAt: '2026-04-13T20:02:00Z', - updatedAt: '2026-04-13T20:02:00Z', - resolvedAt: null, - isPending: true, - isResolved: false, - canResume: false, - isRuntimeResumable: true, - requiresUserAnswer: true, - answerRequirementReason: 'runtime_resumable', - answerRequirementLabel: 'Required', - answerShapeKind: 'plain_text', - answerShapeLabel: 'Required user answer', - answerShapeHint: 'Describe the operator decision that justifies approval.', - answerPlaceholder: 'Provide operator input for this action.', - }, + const viewport = screen.getByLabelText('Agent conversation viewport') + setScrollMetrics(viewport, { + scrollTop: 0, + scrollHeight: 1_000, + clientHeight: 360, }) + fireEvent.scroll(viewport) - render( + expect(screen.getByRole('button', { name: 'Jump to latest' })).toBeVisible() + + scrollIntoView.mockClear() + rerender( , ) - expect(screen.getByRole('heading', { name: 'Checkpoint control loop' })).toBeVisible() - expect(screen.getByLabelText('Operator answer for action-pending')).toBeVisible() - expect(screen.getByText('Xero could not approve action action-pending for boundary boundary-1.')).toBeVisible() + expect(screen.getByText('Working through the runtime.')).toBeVisible() + expect(scrollIntoView).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Jump to latest' })) + + expect(scrollIntoView).toHaveBeenCalledWith({ + block: 'end', + inline: 'nearest', + behavior: 'smooth', + }) + expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument() }) - it('renders checkpoint recovery banners and bounded coverage copy on the Agent tab', () => { - render( + it('pauses auto-follow immediately when the user wheels upward during streaming', () => { + const scrollIntoView = vi.mocked(HTMLElement.prototype.scrollIntoView) + const initialItems: NonNullable = [ + makeTranscriptItem({ sequence: 2, role: 'user', text: 'Walk me through the runtime.' }), + makeTranscriptItem({ sequence: 3, text: 'Working' }), + ] + + const { rerender } = render( , ) - expect(screen.getByText('Remote escalation is actively polling this checkpoint')).toBeVisible() - expect(screen.getByText('Bounded checkpoint coverage')).toBeVisible() - expect(screen.getByText('Live hint only')).toBeVisible() - }) + const viewport = screen.getByLabelText('Agent conversation viewport') + setScrollMetrics(viewport, { + scrollTop: 560, + scrollHeight: 1_000, + clientHeight: 360, + }) + fireEvent.wheel(viewport, { deltaY: -24 }) - it('sends owned-agent live checkpoint responses through runtime run controls', async () => { - const onUpdateRuntimeRunControls = vi.fn(async () => makeRuntimeRun()) - const actionId = 'plan-mode-before-tools' + expect(screen.getByRole('button', { name: 'Jump to latest' })).toBeVisible() - render( + scrollIntoView.mockClear() + rerender( , ) - const responseInput = screen.getByLabelText(`Owned agent response for ${actionId}`) - const sendResponse = screen.getByRole('button', { name: 'Send response' }) - expect(sendResponse).toBeDisabled() + expect(screen.getByText('Working through the runtime.')).toBeVisible() + expect(scrollIntoView).not.toHaveBeenCalled() + }) - fireEvent.change(responseInput, { target: { value: 'Proceed with the approved plan.' } }) - fireEvent.click(screen.getByRole('button', { name: 'Send response' })) + it('preserves subword streamed assistant transcript deltas exactly', () => { + renderRuntimeStreamItems([ + makeTranscriptItem({ sequence: 2, text: 'mon' }), + makeTranscriptItem({ sequence: 3, text: 'om' }), + makeTranscriptItem({ sequence: 4, text: 'orph' }), + makeTranscriptItem({ sequence: 5, text: 'ization' }), + ]) - await waitFor(() => - expect(onUpdateRuntimeRunControls).toHaveBeenCalledWith({ - prompt: 'Proceed with the approved plan.', + expect(screen.getByText('monomorphization')).toBeVisible() + expect(screen.getAllByText('Agent')).toHaveLength(1) + }) + + it('preserves markdown delimiters split across streamed assistant deltas', () => { + renderRuntimeStreamItems([ + makeTranscriptItem({ sequence: 2, text: '**Native binary' }), + makeTranscriptItem({ sequence: 3, text: '** Main modules' }), + ]) + + const boldText = screen.getByText('Native binary') + + expect(boldText.tagName).toBe('STRONG') + expect(boldText.textContent).toBe('Native binary') + expect(boldText.closest('p')).toHaveTextContent('Native binary Main modules') + }) + + it('preserves inline code split across streamed assistant deltas', () => { + renderRuntimeStreamItems([ + makeTranscriptItem({ sequence: 2, text: '`mesh' }), + makeTranscriptItem({ sequence: 3, text: 'c' }), + makeTranscriptItem({ sequence: 4, text: ' build`' }), + ]) + + const codeText = screen.getByText('meshc build') + + expect(codeText.tagName).toBe('CODE') + expect(screen.queryByText('mesh c build')).not.toBeInTheDocument() + }) + + it('renders streamed markdown structure after split transcript deltas are reassembled', () => { + renderRuntimeStreamItems([ + makeTranscriptItem({ sequence: 2, text: '# Pl' }), + makeTranscriptItem({ sequence: 3, text: 'an\n\n- Keep **bo' }), + makeTranscriptItem({ sequence: 4, text: 'ld** text\n- Run `pn' }), + makeTranscriptItem({ sequence: 5, text: 'pm test`\n\n```ts\nconst me' }), + makeTranscriptItem({ sequence: 6, text: 'ssage = "ok"\n```' }), + ]) + + const conversation = screen.getByRole('list', { name: 'Agent conversation turns' }) + const heading = within(conversation).getByText('Plan') + const boldText = within(conversation).getByText('bold') + const inlineCode = within(conversation).getByText('pnpm test') + const codeBlock = within(conversation).getByText('const message = "ok"') + + expect(heading).toHaveClass('font-semibold') + expect(boldText.tagName).toBe('STRONG') + expect(inlineCode.tagName).toBe('CODE') + expect(codeBlock.tagName).toBe('CODE') + expect(boldText.closest('li')).toHaveTextContent('Keep bold text') + expect(inlineCode.closest('li')).toHaveTextContent('Run pnpm test') + }) + + it('renders streamed reasoning activity as an inline thoughts block', () => { + renderRuntimeStreamItems([ + makeTranscriptItem({ sequence: 2, role: 'user', text: 'Why is the build failing?' }), + makeReasoningItem({ sequence: 3, text: 'I should inspect the latest build output' }), + makeReasoningItem({ sequence: 4, text: ' before suggesting a fix.' }), + makeToolItem({ + sequence: 5, + toolCallId: 'call-read-build-log', + toolName: 'read', + toolState: 'succeeded', + detail: 'Read build log.', }), - ) - expect(screen.queryByText('Durable approval row not available')).not.toBeInTheDocument() + makeTranscriptItem({ sequence: 6, text: 'The build is failing because the generated type is stale.' }), + ]) + + expect(screen.getByText('Thoughts')).toBeVisible() + expect(screen.getByText('I should inspect the latest build output before suggesting a fix.')).toBeVisible() + expect(screen.getByText('read')).toBeVisible() + expect(screen.getByText('The build is failing because the generated type is stale.')).toBeVisible() }) + it('keeps consecutive full user transcript items as separate prompts', () => { + renderRuntimeStreamItems([ + makeTranscriptItem({ sequence: 2, role: 'user', text: 'First prompt.' }), + makeTranscriptItem({ sequence: 3, role: 'user', text: 'Second prompt.' }), + ]) + expect(screen.getByText('First prompt.')).toBeVisible() + expect(screen.getByText('Second prompt.')).toBeVisible() + expect(screen.getAllByText('You')).toHaveLength(2) + }) + + it('collapses tool state transitions into one compact card with details', () => { + renderRuntimeStreamItems([ + makeToolItem({ + sequence: 2, + toolCallId: 'call-read', + toolName: 'read', + toolState: 'running', + detail: 'path: client/components/xero/agent-runtime.tsx, startLine: 1, lineCount: 80', + }), + makeToolItem({ + sequence: 3, + toolCallId: 'call-read', + toolName: 'read', + toolState: 'succeeded', + detail: 'Read 80 line(s) from `client/components/xero/agent-runtime.tsx`.', + toolSummary: { + kind: 'file', + path: 'client/components/xero/agent-runtime.tsx', + scope: null, + lineCount: 80, + matchCount: null, + truncated: false, + }, + }), + ]) + expect(screen.getAllByText('read agent-runtime.tsx')).toHaveLength(1) + expect(screen.getByText('Succeeded')).toBeVisible() + expect(screen.queryByText('Running')).not.toBeInTheDocument() + expect(screen.getByText('Read 80 line(s) from `client/components/xero/agent-runtime.tsx`.')).toBeVisible() + expect(screen.queryByText('Tool activity recorded.')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /show tool details for read agent-runtime\.tsx/i })) + expect(screen.getByText('Input')).toBeVisible() + expect(screen.getByText('path: client/components/xero/agent-runtime.tsx, startLine: 1, lineCount: 80')).toBeVisible() + expect(screen.getByText('Result')).toBeVisible() + expect(screen.getByText('File result · path client/components/xero/agent-runtime.tsx · 80 lines')).toBeVisible() + }) + it('uses action plus target labels for search-oriented tool cards', () => { + renderRuntimeStreamItems([ + makeToolItem({ + sequence: 2, + toolCallId: 'call-find', + toolName: 'find', + toolState: 'running', + detail: 'pattern: appendTranscriptDelta, path: client/components/xero', + }), + makeToolItem({ + sequence: 3, + toolCallId: 'call-list', + toolName: 'list', + toolState: 'running', + detail: 'path: client/components/xero, maxDepth: 2', + }), + ]) + expect(screen.getByText('find appendTranscriptDelta')).toBeVisible() + expect(screen.getByText('list client/components/xero')).toBeVisible() + }) + it('groups long tool bursts without evicting the surrounding transcript turns', () => { + const toolBurst = Array.from({ length: 30 }, (_, index) => + makeToolItem({ + sequence: index + 3, + toolCallId: `call-read-${index}`, + toolName: 'read', + toolState: 'succeeded', + detail: `Read tool ${index}.`, + toolSummary: { + kind: 'file', + path: `client/src/tool-${index}.ts`, + scope: null, + lineCount: 12, + matchCount: null, + truncated: false, + }, + }), + ) + renderRuntimeStreamItems([ + makeTranscriptItem({ sequence: 2, role: 'user', text: 'Please inspect the codebase.' }), + ...toolBurst, + makeTranscriptItem({ sequence: 40, role: 'assistant', text: 'Inspection complete.' }), + ]) + expect(screen.getByText('Please inspect the codebase.')).toBeVisible() + expect(screen.getByText('Inspection complete.')).toBeVisible() + expect(screen.getByText('30 tool calls')).toBeVisible() + expect(screen.queryByText('read tool-0.ts')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /show grouped tool details for 30 tool calls/i })) + expect(screen.getByText('read tool-0.ts')).toBeVisible() + expect(screen.getByText('read tool-29.ts')).toBeVisible() + }) it('offers diagnostics from runtime startup failures', () => { const onOpenDiagnostics = vi.fn() @@ -1169,6 +1171,36 @@ describe('AgentRuntime current UI', () => { expect(onOpenDiagnostics).toHaveBeenCalledTimes(1) }) + it('renders Debug as an approval-capable composer agent', () => { + render( + , + ) + + expect(screen.getByRole('combobox', { name: 'Agent selector' })).toHaveTextContent('Debug') + expect(screen.getByRole('combobox', { name: 'Approval mode selector' })).toHaveTextContent('Auto edit') + }) + + it('renders Agent Create as a built-in suggest-only composer agent', () => { + render( + , + ) + + expect(screen.getByRole('combobox', { name: 'Agent selector' })).toHaveTextContent('Agent Create') + expect(screen.queryByRole('combobox', { name: 'Approval mode selector' })).not.toBeInTheDocument() + }) + it('keeps model selectors available while a prompt is pending on an active run', async () => { const onUpdateRuntimeRunControls = vi.fn(async () => makeRuntimeRun()) @@ -1215,6 +1247,8 @@ describe('AgentRuntime current UI', () => { }, runtimeRunActiveControls: { providerProfileId: null, + agentDefinitionId: null, + agentDefinitionVersion: null, runtimeAgentId: 'engineer', runtimeAgentLabel: 'Engineer', modelId: 'openai_codex', @@ -1228,6 +1262,8 @@ describe('AgentRuntime current UI', () => { }, runtimeRunPendingControls: { providerProfileId: null, + agentDefinitionId: null, + agentDefinitionVersion: null, runtimeAgentId: 'engineer', runtimeAgentLabel: 'Engineer', modelId: 'anthropic/claude-3.5-haiku', @@ -1337,6 +1373,8 @@ describe('AgentRuntime current UI', () => { }, runtimeRunActiveControls: { providerProfileId: null, + agentDefinitionId: null, + agentDefinitionVersion: null, runtimeAgentId: 'ask', runtimeAgentLabel: 'Ask', modelId: 'openai_codex', @@ -1350,6 +1388,8 @@ describe('AgentRuntime current UI', () => { }, runtimeRunPendingControls: { providerProfileId: null, + agentDefinitionId: null, + agentDefinitionVersion: null, runtimeAgentId: 'ask', runtimeAgentLabel: 'Ask', modelId: 'openai_codex', @@ -1536,6 +1576,8 @@ describe('AgentRuntime current UI', () => { selectedApprovalMode: 'yolo', runtimeRunActiveControls: { providerProfileId: null, + agentDefinitionId: null, + agentDefinitionVersion: null, runtimeAgentId: 'engineer', runtimeAgentLabel: 'Engineer', modelId: 'openai_codex', @@ -1562,6 +1604,7 @@ describe('AgentRuntime current UI', () => { prompt: 'Queue the next prompt.', }), ) + await waitFor(() => expect(screen.getByLabelText('Agent input')).toHaveValue('')) expect(screen.getByRole('combobox', { name: 'Approval mode selector' })).toHaveTextContent('YOLO') }) diff --git a/client/components/xero/agent-runtime.tsx b/client/components/xero/agent-runtime.tsx index 60c17e9e..9174adda 100644 --- a/client/components/xero/agent-runtime.tsx +++ b/client/components/xero/agent-runtime.tsx @@ -1,24 +1,29 @@ "use client" -import { useMemo } from 'react' -import { ChevronRight, Loader2, Plus } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from 'react' +import { ArrowDown, ChevronRight, Loader2, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import type { AgentPaneView, AgentProviderModelView, } from '@/src/features/xero/use-xero-desktop-state' +import type { XeroDesktopAdapter } from '@/src/lib/xero-desktop' import type { + AgentDefinitionSummaryDto, + AgentSessionView, RuntimeRunView, RuntimeAutoCompactPreferenceDto, ProviderAuthSessionView, RuntimeSessionView, - RuntimeStreamActionRequiredItemView, + RuntimeStreamActivityItemView, RuntimeStreamFailureItemView, RuntimeStreamToolItemView, RuntimeStreamViewItem, UpsertNotificationRouteRequestDto, } from '@/src/lib/xero-model' +import type { SessionContextSnapshotDto } from '@/src/lib/xero-model/session-context' import { getRuntimeAgentLabel, getRuntimeRunThinkingEffortLabel, @@ -26,11 +31,9 @@ import { } from '@/src/lib/xero-model' import { - createEmptyCheckpointControlLoop, - getCheckpointControlLoopCoverageAlertMeta, - getCheckpointControlLoopRecoveryAlertMeta, -} from './agent-runtime/checkpoint-control-loop-helpers' -import { CheckpointControlLoopSection } from './agent-runtime/checkpoint-control-loop-section' + AgentContextMeter, + type AgentContextMeterStatus, +} from './agent-runtime/agent-context-meter' import { getComposerApprovalOptions, getComposerModelGroups, @@ -39,10 +42,13 @@ import { getComposerThinkingOptions, } from './agent-runtime/composer-helpers' import { ComposerDock } from './agent-runtime/composer-dock' +import { AgentCreateDraftSection } from './agent-runtime/agent-create-draft-section' import { ConversationSection, type ConversationTurn } from './agent-runtime/conversation-section' import { EmptySessionState } from './agent-runtime/empty-session-state' import { + getToolCardTitle, getStreamRunId, + getToolStateLabel, getToolSummaryContext, hasUsableRuntimeRunId, } from './agent-runtime/runtime-stream-helpers' @@ -50,6 +56,8 @@ import { SetupEmptyState } from './agent-runtime/setup-empty-state' import { useAgentRuntimeController } from './agent-runtime/use-agent-runtime-controller' import type { SpeechDictationAdapter } from './agent-runtime/use-speech-dictation' +type AgentRuntimeDesktopAdapter = SpeechDictationAdapter & Partial> + interface AgentRuntimeProps { agent: AgentPaneView onOpenSettings?: () => void @@ -83,63 +91,262 @@ interface AgentRuntimeProps { onUpsertNotificationRoute?: ( request: Omit, ) => Promise - desktopAdapter?: SpeechDictationAdapter + desktopAdapter?: AgentRuntimeDesktopAdapter /** GitHub avatar URL for the signed-in account, when available. */ accountAvatarUrl?: string | null /** GitHub login for the signed-in account. */ accountLogin?: string | null onCreateSession?: () => void isCreatingSession?: boolean + /** Active and known custom agent definitions visible to the composer selector. */ + customAgentDefinitions?: readonly AgentDefinitionSummaryDto[] + /** Open the Settings → Agents tab so the user can manage custom agents. */ + onOpenAgentManagement?: () => void } const EMPTY_ACTION_REQUIRED_ITEMS: NonNullable = [] -const MAX_VISIBLE_RUNTIME_FEED_ITEMS = 24 +const MAX_VISIBLE_RUNTIME_ACTION_TURNS = 16 +const COMPACT_TOOL_BURST_THRESHOLD = 5 +const CONVERSATION_NEAR_BOTTOM_THRESHOLD_PX = 96 -function appendTranscriptDelta(current: string, delta: string): string { - if (!current) { - return delta +export function isRuntimeConversationNearBottom( + viewport: Pick, + thresholdPx = CONVERSATION_NEAR_BOTTOM_THRESHOLD_PX, +): boolean { + if (viewport.scrollHeight <= viewport.clientHeight) { + return true } - if (!delta) { - return current - } + return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= thresholdPx +} - if (/\s$/.test(current) || /^\s/.test(delta) || /^[.,!?;:%)\]}]/.test(delta)) { - return `${current}${delta}` - } +function appendTranscriptDelta(current: string, delta: string): string { + return `${current}${delta}` +} - return `${current} ${delta}` +function shouldShowActionItem(item: RuntimeStreamViewItem): item is RuntimeStreamToolItemView | RuntimeStreamFailureItemView { + return item.kind === 'tool' || item.kind === 'failure' } -function shouldShowActionItem(item: RuntimeStreamViewItem): item is RuntimeStreamToolItemView | RuntimeStreamActionRequiredItemView | RuntimeStreamFailureItemView { - return item.kind === 'tool' || item.kind === 'action_required' || item.kind === 'failure' +function isReasoningActivityItem(item: RuntimeStreamViewItem): item is RuntimeStreamActivityItemView { + return item.kind === 'activity' && item.code === 'owned_agent_reasoning' } -function actionTurnFromItem(item: RuntimeStreamToolItemView | RuntimeStreamActionRequiredItemView): ConversationTurn { - if (item.kind === 'action_required') { - return { - id: item.id, - kind: 'action', - sequence: item.sequence, - title: item.title, - detail: item.detail, - state: null, - } - } +function getReasoningActivityText(item: RuntimeStreamActivityItemView): string { + return item.text ?? item.detail ?? '' +} +function appendThinkingDelta(current: string, delta: string): string { + return `${current}${delta}` +} + +function actionTurnFromItem(item: RuntimeStreamToolItemView): ConversationTurn { const summary = getToolSummaryContext(item) + const detail = getActionDetail(item, summary) return { id: item.id, kind: 'action', sequence: item.sequence, - title: item.toolName, - detail: item.detail ?? summary ?? 'Tool activity recorded.', + toolCallId: item.toolCallId, + toolName: item.toolName, + title: getToolCardTitle(item), + detail, + detailRows: getActionDetailRows(item, summary), state: item.toolState, } } +function normalizeToolCopy(value: string): string { + return value.trim().replace(/[._-]+/g, ' ').replace(/\s+/g, ' ').toLowerCase() +} + +function isGenericToolDetail(detail: string, item: RuntimeStreamToolItemView): boolean { + const normalizedDetail = normalizeToolCopy(detail) + return normalizedDetail === normalizeToolCopy(item.toolName) || normalizedDetail === 'tool activity recorded' +} + +function getActionDetail(item: RuntimeStreamToolItemView, summary: string | null): string { + if (item.detail && (!summary || !isGenericToolDetail(item.detail, item))) { + return item.detail + } + + return summary ?? item.detail ?? 'Tool activity recorded.' +} + +function getActionDetailRows( + item: RuntimeStreamToolItemView, + summary: string | null, +): Array<{ label: string; value: string }> { + const rows: Array<{ label: string; value: string }> = [] + + if (item.detail) { + rows.push({ + label: item.toolState === 'running' ? 'Input' : 'Outcome', + value: item.detail, + }) + } + + if (summary && summary !== item.detail) { + rows.push({ + label: 'Result', + value: summary, + }) + } + + return rows +} + +function mergeActionRows( + existing: Array<{ label: string; value: string }>, + incoming: Array<{ label: string; value: string }>, +): Array<{ label: string; value: string }> { + const seen = new Set(existing.map((row) => `${row.label}\u0000${row.value}`)) + const merged = [...existing] + + for (const row of incoming) { + const key = `${row.label}\u0000${row.value}` + if (!seen.has(key)) { + seen.add(key) + merged.push(row) + } + } + + return merged +} + +function mergeActionTurn(existing: ConversationTurn, incoming: ConversationTurn): void { + if (existing.kind !== 'action' || incoming.kind !== 'action') { + return + } + + existing.sequence = incoming.sequence + existing.state = incoming.state + existing.detail = incoming.detail + existing.detailRows = mergeActionRows(existing.detailRows, incoming.detailRows) + + if (incoming.title.length >= existing.title.length) { + existing.title = incoming.title + } +} + +function isActionLikeTurn( + turn: ConversationTurn, +): turn is Extract { + return turn.kind === 'action' || turn.kind === 'action_group' +} + +function actionGroupState( + actions: Extract[], +): RuntimeStreamToolItemView['toolState'] | null { + if (actions.some((action) => action.state === 'failed')) { + return 'failed' + } + if (actions.some((action) => action.state === 'running')) { + return 'running' + } + if (actions.some((action) => action.state === 'pending')) { + return 'pending' + } + if (actions.some((action) => action.state === 'succeeded')) { + return 'succeeded' + } + return null +} + +function summarizeActionGroup( + actions: Extract[], +): string { + const stateCounts = new Map() + for (const action of actions) { + if (action.state) { + stateCounts.set(action.state, (stateCounts.get(action.state) ?? 0) + 1) + } + } + + const stateSummary = (['failed', 'running', 'pending', 'succeeded'] as const) + .map((state) => { + const count = stateCounts.get(state) ?? 0 + return count > 0 ? `${count} ${getToolStateLabel(state).toLowerCase()}` : null + }) + .filter((part): part is string => Boolean(part)) + .join(' · ') + const latestAction = actions.at(-1) + + return [ + stateSummary || `${actions.length} recorded`, + latestAction ? `latest ${latestAction.title}` : null, + ] + .filter((part): part is string => Boolean(part)) + .join(' · ') +} + +function actionGroupTurnFromActions( + actions: Extract[], +): ConversationTurn { + const firstAction = actions[0] + const lastAction = actions.at(-1) ?? firstAction + + return { + id: `tool-group:${firstAction.id}:${lastAction.id}`, + kind: 'action_group', + sequence: lastAction.sequence, + title: `${actions.length} tool calls`, + detail: summarizeActionGroup(actions), + state: actionGroupState(actions), + actions: actions.map((action) => ({ + id: action.id, + title: action.title, + detail: action.detail, + state: action.state ?? null, + })), + } +} + +function compactActionBursts(turns: ConversationTurn[]): ConversationTurn[] { + const compactedTurns: ConversationTurn[] = [] + let actionBuffer: Extract[] = [] + + const flushActionBuffer = () => { + if (actionBuffer.length >= COMPACT_TOOL_BURST_THRESHOLD) { + compactedTurns.push(actionGroupTurnFromActions(actionBuffer)) + } else { + compactedTurns.push(...actionBuffer) + } + actionBuffer = [] + } + + for (const turn of turns) { + if (turn.kind === 'action') { + actionBuffer.push(turn) + continue + } + + flushActionBuffer() + compactedTurns.push(turn) + } + + flushActionBuffer() + return compactedTurns +} + +function limitActionTurns(turns: ConversationTurn[]): ConversationTurn[] { + const actionTurnIndexes = turns + .map((turn, index) => (isActionLikeTurn(turn) ? index : null)) + .filter((index): index is number => index != null) + + if (actionTurnIndexes.length <= MAX_VISIBLE_RUNTIME_ACTION_TURNS) { + return turns + } + + const keptActionTurnIndexes = new Set( + actionTurnIndexes.slice(actionTurnIndexes.length - MAX_VISIBLE_RUNTIME_ACTION_TURNS), + ) + return turns.filter((turn, index) => !isActionLikeTurn(turn) || keptActionTurnIndexes.has(index)) +} + function buildConversationTurns(runtimeStreamItems: RuntimeStreamViewItem[]): ConversationTurn[] { const turns: ConversationTurn[] = [] + const actionTurnIndexByToolCallId = new Map() for (const item of runtimeStreamItems) { if (item.kind === 'transcript') { @@ -148,7 +355,7 @@ function buildConversationTurns(runtimeStreamItems: RuntimeStreamViewItem[]): Co } const previous = turns.at(-1) - if (previous?.kind === 'message' && previous.role === item.role) { + if (item.role === 'assistant' && previous?.kind === 'message' && previous.role === item.role) { previous.text = appendTranscriptDelta(previous.text, item.text) previous.sequence = item.sequence continue @@ -164,6 +371,33 @@ function buildConversationTurns(runtimeStreamItems: RuntimeStreamViewItem[]): Co continue } + if (isReasoningActivityItem(item)) { + const text = getReasoningActivityText(item) + if (text.trim().length === 0) { + const previousThinking = turns.at(-1) + if (previousThinking?.kind === 'thinking') { + previousThinking.text = appendThinkingDelta(previousThinking.text, text) + previousThinking.sequence = item.sequence + } + continue + } + + const previous = turns.at(-1) + if (previous?.kind === 'thinking') { + previous.text = appendThinkingDelta(previous.text, text) + previous.sequence = item.sequence + continue + } + + turns.push({ + id: item.id, + kind: 'thinking', + sequence: item.sequence, + text, + }) + continue + } + if (!shouldShowActionItem(item)) { continue } @@ -179,10 +413,131 @@ function buildConversationTurns(runtimeStreamItems: RuntimeStreamViewItem[]): Co continue } - turns.push(actionTurnFromItem(item)) + const incomingActionTurn = actionTurnFromItem(item) + const existingActionTurnIndex = actionTurnIndexByToolCallId.get(item.toolCallId) + const existingActionTurn = + existingActionTurnIndex != null ? turns[existingActionTurnIndex] : null + + if (existingActionTurn?.kind === 'action') { + mergeActionTurn(existingActionTurn, incomingActionTurn) + continue + } + + actionTurnIndexByToolCallId.set(item.toolCallId, turns.length) + turns.push(incomingActionTurn) } - return turns.slice(-MAX_VISIBLE_RUNTIME_FEED_ITEMS) + return limitActionTurns(compactActionBursts(turns)) +} + +function toContextMeterError(error: unknown): { + code: string + message: string + retryable: boolean +} { + const candidate = error as { code?: unknown; retryable?: unknown; message?: unknown } | null + return { + code: typeof candidate?.code === 'string' && candidate.code.trim().length > 0 + ? candidate.code + : 'agent_context_meter_failed', + message: typeof candidate?.message === 'string' && candidate.message.trim().length > 0 + ? candidate.message + : error instanceof Error && error.message.trim().length > 0 + ? error.message + : 'Xero could not refresh the context meter.', + retryable: typeof candidate?.retryable === 'boolean' ? candidate.retryable : true, + } +} + +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const timeout = window.setTimeout(() => setDebounced(value), delayMs) + return () => window.clearTimeout(timeout) + }, [delayMs, value]) + + return debounced +} + +function useAgentContextMeterSnapshot(options: { + adapter?: AgentRuntimeDesktopAdapter + projectId: string + agentSessionId: string | null + runId: string | null + providerId: string | null + modelId: string | null + pendingPrompt: string + lifecycleKey: string +}): { + status: AgentContextMeterStatus + snapshot: SessionContextSnapshotDto | null + error: ReturnType | null + refresh: () => void +} { + const debouncedPendingPrompt = useDebouncedValue(options.pendingPrompt, 350) + const debouncedLifecycleKey = useDebouncedValue(options.lifecycleKey, 400) + const [status, setStatus] = useState('idle') + const [snapshot, setSnapshot] = useState(null) + const [error, setError] = useState | null>(null) + const requestIdRef = useRef(0) + const snapshotRef = useRef(null) + + useEffect(() => { + snapshotRef.current = snapshot + }, [snapshot]) + + const refresh = useCallback(() => { + if (!options.adapter?.getSessionContextSnapshot || !options.agentSessionId) { + requestIdRef.current += 1 + setStatus('idle') + setSnapshot(null) + setError(null) + return + } + + const requestId = requestIdRef.current + 1 + requestIdRef.current = requestId + if (!snapshotRef.current) { + setStatus('loading') + } + setError(null) + + void options.adapter + .getSessionContextSnapshot({ + projectId: options.projectId, + agentSessionId: options.agentSessionId, + runId: options.runId, + providerId: options.providerId, + modelId: options.modelId, + pendingPrompt: debouncedPendingPrompt, + }) + .then((nextSnapshot) => { + if (requestIdRef.current !== requestId) return + setSnapshot(nextSnapshot) + setStatus('ready') + setError(null) + }) + .catch((nextError) => { + if (requestIdRef.current !== requestId) return + setError(toContextMeterError(nextError)) + setStatus('error') + }) + }, [ + debouncedPendingPrompt, + options.adapter, + options.agentSessionId, + options.modelId, + options.projectId, + options.providerId, + options.runId, + ]) + + useEffect(() => { + refresh() + }, [refresh, debouncedLifecycleKey]) + + return { status, snapshot, error, refresh } } export function AgentRuntime({ @@ -201,6 +556,8 @@ export function AgentRuntime({ accountLogin = null, onCreateSession, isCreatingSession = false, + customAgentDefinitions = [], + onOpenAgentManagement, }: AgentRuntimeProps) { const runtimeSession = agent.runtimeSession ?? null const runtimeRun = agent.runtimeRun ?? null @@ -216,6 +573,28 @@ export function AgentRuntime({ const toolCalls = runtimeStream?.toolCalls ?? [] const streamIssue = agent.runtimeStreamError ?? runtimeStream?.lastIssue ?? null const visibleTurns = useMemo(() => buildConversationTurns(runtimeStreamItems), [runtimeStreamItems]) + const pendingRuntimeRunAction = agent.pendingRuntimeRunAction ?? null + const runtimeRunActionStatus = agent.runtimeRunActionStatus + const isQueueingRuntimePrompt = + runtimeRunActionStatus === 'running' && + (pendingRuntimeRunAction === 'start' || pendingRuntimeRunAction === 'update_controls') + const showAgentActivityIndicator = Boolean( + isQueueingRuntimePrompt || + agent.selectedPrompt.hasQueuedPrompt || + ( + renderableRuntimeRun?.isActive && + streamStatus !== 'complete' && + streamStatus !== 'error' && + !runtimeStream?.failure + ), + ) + const hasUserMessage = useMemo( + () => runtimeStreamItems.some((item) => item.kind === 'transcript' && item.role === 'user'), + [runtimeStreamItems], + ) + const selectedAgentSession = (agent.project.selectedAgentSession ?? null) as AgentSessionView | null + const selectedAgentSessionId = + selectedAgentSession?.agentSessionId ?? agent.project.selectedAgentSessionId ?? null const selectedProviderId = agent.selectedModel?.providerId ?? agent.selectedProviderId ?? runtimeSession?.providerId ?? 'openai_codex' @@ -268,6 +647,8 @@ export function AgentRuntime({ projectId: agent.project.id, selectedModelSelectionKey: agent.selectedModelSelectionKey ?? agent.selectedModelOption?.selectionKey ?? selectedModelId, selectedRuntimeAgentId: agent.selectedRuntimeAgentId, + selectedAgentDefinitionId: agent.runtimeRunActiveControls?.agentDefinitionId ?? null, + customAgentDefinitions, selectedThinkingEffort: agent.selectedThinkingEffort, selectedApprovalMode: agent.selectedApprovalMode, selectedPrompt: agent.selectedPrompt, @@ -307,32 +688,40 @@ export function AgentRuntime({ () => getComposerThinkingOptions(selectedComposerModel), [selectedComposerModel], ) - const composerApprovalOptions = useMemo(() => getComposerApprovalOptions(), []) + const composerApprovalOptions = useMemo( + () => getComposerApprovalOptions(controller.composerRuntimeAgentId), + [controller.composerRuntimeAgentId], + ) + const streamRunId = getStreamRunId(runtimeStream, renderableRuntimeRun) + const contextMeterState = useAgentContextMeterSnapshot({ + adapter: desktopAdapter, + projectId: agent.project.id, + agentSessionId: selectedAgentSessionId, + runId: renderableRuntimeRun?.runId ?? streamRunId ?? null, + providerId: selectedComposerModel?.providerId ?? selectedProviderId, + modelId: selectedComposerModel?.modelId ?? selectedModelId, + pendingPrompt: controller.draftPrompt, + lifecycleKey: [ + renderableRuntimeRun?.runId ?? 'no-run', + renderableRuntimeRun?.updatedAt ?? 'no-run-update', + runtimeStream?.status ?? 'idle', + runtimeStream?.lastItemAt ?? 'no-stream-update', + agent.pendingRuntimeRunAction ?? 'no-action', + ].join(':'), + }) + const contextMeter = + contextMeterState.status === 'idle' ? null : ( + + ) const composerThinkingPlaceholder = controller.composerThinkingEffort ? getRuntimeRunThinkingEffortLabel(controller.composerThinkingEffort) : controller.composerModelId ? 'Thinking unavailable' : 'Choose model' - const streamRunId = getStreamRunId(runtimeStream, renderableRuntimeRun) - const checkpointControlLoop = agent.checkpointControlLoop ?? createEmptyCheckpointControlLoop() - const checkpointControlLoopRecoveryAlert = getCheckpointControlLoopRecoveryAlertMeta({ - controlLoop: checkpointControlLoop, - trustSnapshot: { - syncState: agent.trustSnapshot?.syncState ?? 'unavailable', - syncReason: - agent.trustSnapshot?.syncReason ?? - 'Xero has not projected notification sync trust for this project yet.', - }, - autonomousRunErrorMessage: agent.autonomousRunErrorMessage, - notificationSyncPollingActive: agent.notificationSyncPollingActive ?? false, - notificationSyncPollingActionId: agent.notificationSyncPollingActionId ?? null, - notificationSyncPollingBoundaryId: agent.notificationSyncPollingBoundaryId ?? null, - }) - const checkpointControlLoopCoverageAlert = getCheckpointControlLoopCoverageAlertMeta(checkpointControlLoop) - const showCheckpointControlLoopSection = - checkpointControlLoop.items.length > 0 || - Boolean(checkpointControlLoopRecoveryAlert) || - Boolean(checkpointControlLoopCoverageAlert) const baseComposerPlaceholder = getComposerPlaceholder( runtimeSession, streamStatus, @@ -349,6 +738,11 @@ export function AgentRuntime({ runtimeSession?.isAuthenticated && !renderableRuntimeRun?.isTerminal ? 'Ask about this project...' + : controller.composerRuntimeAgentId === 'agent_create' && + !agentRuntimeBlocked && + runtimeSession?.isAuthenticated && + !renderableRuntimeRun?.isTerminal + ? 'Describe the agent you want to create...' : baseComposerPlaceholder const showAgentSetupEmptyState = Boolean( agentRuntimeBlocked && @@ -359,6 +753,7 @@ export function AgentRuntime({ renderableRuntimeRun || controller.recentRunReplacement || streamIssue || + showAgentActivityIndicator || transcriptItems.length > 0 || activityItems.length > 0 || toolCalls.length > 0 || @@ -376,91 +771,197 @@ export function AgentRuntime({ const showEmptySessionState = Boolean( !showAgentSetupEmptyState && !agentRuntimeBlocked && isProviderLoggedIn && !hasSessionActivity, ) + const hasConversationViewportContent = Boolean( + !showAgentSetupEmptyState && !showEmptySessionState && hasSessionActivity, + ) const projectLabel = agent.project.repository?.displayName ?? agent.project.name ?? 'this project' const sessionLabel = agent.project.selectedAgentSession?.title?.trim() || 'New Chat' + const scrollViewportRef = useRef(null) + const bottomSentinelRef = useRef(null) + const shouldAutoFollowRef = useRef(true) + const [showJumpToLatest, setShowJumpToLatest] = useState(false) + const latestVisibleTurn = visibleTurns.at(-1) + const conversationScrollKey = [ + latestVisibleTurn?.id ?? 'none', + latestVisibleTurn?.sequence ?? 'none', + latestVisibleTurn?.kind === 'message' + ? latestVisibleTurn.text.length + : latestVisibleTurn?.kind === 'action' + ? `${latestVisibleTurn.state ?? 'unknown'}:${latestVisibleTurn.detail.length}` + : latestVisibleTurn?.kind === 'failure' + ? latestVisibleTurn.message.length + : 'none', + runtimeStream?.completion?.id ?? 'no-completion', + runtimeStream?.failure?.id ?? 'no-failure', + streamIssue?.code ?? 'no-issue', + ].join(':') + const scrollToLatest = useCallback((behavior: ScrollBehavior = 'auto') => { + bottomSentinelRef.current?.scrollIntoView({ + block: 'end', + inline: 'nearest', + behavior, + }) + }, []) + const handleConversationScroll = useCallback(() => { + const viewport = scrollViewportRef.current + if (!viewport) { + return + } + + const isNearBottom = isRuntimeConversationNearBottom(viewport) + shouldAutoFollowRef.current = isNearBottom + setShowJumpToLatest(hasConversationViewportContent && !isNearBottom) + }, [hasConversationViewportContent]) + const pauseConversationAutoFollow = useCallback(() => { + if (!hasConversationViewportContent) { + return + } + + shouldAutoFollowRef.current = false + setShowJumpToLatest(true) + }, [hasConversationViewportContent]) + const handleConversationWheel = useCallback((event: WheelEvent) => { + const viewport = scrollViewportRef.current + if (event.deltaY < 0 && viewport && viewport.scrollHeight > viewport.clientHeight) { + pauseConversationAutoFollow() + } + }, [pauseConversationAutoFollow]) + const handleJumpToLatest = useCallback(() => { + shouldAutoFollowRef.current = true + setShowJumpToLatest(false) + scrollToLatest('smooth') + }, [scrollToLatest]) + const handleSubmitDraftPrompt = useCallback(() => { + shouldAutoFollowRef.current = true + setShowJumpToLatest(false) + scrollToLatest('auto') + void controller.handleSubmitDraftPrompt().finally(() => { + scrollToLatest('auto') + }) + }, [controller, scrollToLatest]) + + useEffect(() => { + if (!hasConversationViewportContent) { + shouldAutoFollowRef.current = true + setShowJumpToLatest(false) + return + } + + if (shouldAutoFollowRef.current) { + scrollToLatest('auto') + setShowJumpToLatest(false) + return + } + + setShowJumpToLatest(true) + }, [conversationScrollKey, hasConversationViewportContent, scrollToLatest]) return (
-
-
- {projectLabel} - - {sessionLabel} +
+
+
+ {projectLabel} + + {sessionLabel} +
+ {onCreateSession ? ( + + ) : null}
- {onCreateSession ? ( -
+
+
+ {showAgentSetupEmptyState ? ( + + ) : showEmptySessionState ? ( + { + controller.handleDraftPromptChange(prompt) + controller.promptInputRef.current?.focus() + }} + /> + ) : ( +
+ + {controller.composerRuntimeAgentId === 'agent_create' ? ( + + ) : null} + + )} +
+ {showJumpToLatest ? ( + + + Jump to latest + ) : null}
-
- {showAgentSetupEmptyState ? ( - - ) : showEmptySessionState ? ( - { - controller.handleDraftPromptChange(prompt) - controller.promptInputRef.current?.focus() - }} - /> - ) : ( -
- - {showCheckpointControlLoopSection ? ( - - ) : null} -
- )} -
void controller.handleSubmitDraftPrompt()} - pendingRuntimeRunAction={agent.pendingRuntimeRunAction ?? null} + onSubmitDraftPrompt={handleSubmitDraftPrompt} + pendingRuntimeRunAction={pendingRuntimeRunAction} placeholder={composerPlaceholder} promptInputRef={controller.promptInputRef} promptInputLabel={promptInputLabel} runtimeSessionBindInFlight={controller.runtimeSessionBindInFlight} runtimeRunActionError={controller.runtimeRunActionError} runtimeRunActionErrorTitle={controller.runtimeRunActionErrorTitle} - runtimeRunActionStatus={agent.runtimeRunActionStatus} + runtimeRunActionStatus={runtimeRunActionStatus} sendButtonLabel={sendButtonLabel} onOpenDiagnostics={onOpenDiagnostics} /> diff --git a/client/components/xero/agent-runtime/agent-context-meter.test.tsx b/client/components/xero/agent-runtime/agent-context-meter.test.tsx new file mode 100644 index 00000000..bfd4d2c4 --- /dev/null +++ b/client/components/xero/agent-runtime/agent-context-meter.test.tsx @@ -0,0 +1,203 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { AgentContextMeter } from '@/components/xero/agent-runtime/agent-context-meter' +import { + XERO_SESSION_CONTEXT_CONTRACT_VERSION, + createPublicSessionContextRedaction, + type SessionContextBudgetDto, + type SessionContextSnapshotDto, +} from '@/src/lib/xero-model/session-context' + +const baseBudget: SessionContextBudgetDto = { + budgetTokens: 100_000, + contextWindowTokens: 128_000, + effectiveInputBudgetTokens: 100_000, + maxOutputTokens: 16_000, + outputReserveTokens: 16_000, + safetyReserveTokens: 12_000, + remainingTokens: 58_000, + pressurePercent: 42, + estimatedTokens: 42_000, + estimationSource: 'estimated', + pressure: 'medium', + knownProviderBudget: true, + limitSource: 'live_catalog', + limitConfidence: 'high', + limitDiagnostic: 'OpenRouter reported context_length.', + limitFetchedAt: '2026-05-01T14:00:00Z', +} + +function makeSnapshot(overrides: { + budget?: SessionContextBudgetDto + modelId?: string +} = {}): SessionContextSnapshotDto { + const budget = overrides.budget ?? baseBudget + return { + contractVersion: XERO_SESSION_CONTEXT_CONTRACT_VERSION, + snapshotId: 'snapshot-1', + projectId: 'project-1', + agentSessionId: 'agent-session-1', + runId: 'run-1', + providerId: 'openrouter', + modelId: overrides.modelId ?? 'gpt-5.4', + generatedAt: '2026-05-01T14:02:00Z', + budget, + providerRequestHash: 'a'.repeat(64), + includedTokenEstimate: budget.estimatedTokens, + deferredTokenEstimate: 0, + codeMap: { + generatedFromRoot: 'xero', + sourceRoots: [], + packageManifests: [], + symbols: [], + redaction: createPublicSessionContextRedaction(), + }, + diff: null, + contributors: [ + { + contributorId: 'conversation-tail', + kind: 'conversation_tail', + label: 'Recent conversation', + promptFragmentId: null, + promptFragmentPriority: null, + promptFragmentHash: null, + promptFragmentProvenance: null, + projectId: 'project-1', + agentSessionId: 'agent-session-1', + runId: 'run-1', + sourceId: 'run-1', + sequence: 1, + estimatedTokens: budget.estimatedTokens, + estimatedChars: budget.estimatedTokens * 4, + recencyScore: 100, + relevanceScore: 85, + authorityScore: 70, + rankScore: 850, + taskPhase: 'execute', + disposition: 'include', + included: true, + modelVisible: true, + summary: null, + omittedReason: null, + text: 'Recent model-visible conversation context.', + redaction: createPublicSessionContextRedaction(), + }, + ], + policyDecisions: [ + { + contractVersion: XERO_SESSION_CONTEXT_CONTRACT_VERSION, + decisionId: 'policy-1', + kind: 'compaction', + action: 'none', + trigger: 'auto', + reasonCode: 'budget_ok', + message: 'Context budget is safe.', + rawTranscriptPreserved: true, + modelVisible: false, + redaction: createPublicSessionContextRedaction(), + }, + ], + usageTotals: null, + redaction: createPublicSessionContextRedaction(), + } +} + +describe('AgentContextMeter', () => { + it('shows known budget pressure with progress semantics from the backend projection', () => { + render( + , + ) + + expect(screen.getByText('58% left')).toHaveClass('hidden', 'sm:inline') + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42') + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuetext', + '58 percent context remaining for gpt-5.4', + ) + }) + + it('masks known system prompt usage until the first user message is sent', () => { + render( + , + ) + + expect(screen.getByText('Full')).toHaveClass('hidden', 'sm:inline') + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0') + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuetext', + '100 percent context remaining for gpt-5.4', + ) + }) + + it('keeps unknown model budgets explicit and avoids fake progress percentages', () => { + const unknownBudget: SessionContextBudgetDto = { + ...baseBudget, + budgetTokens: null, + contextWindowTokens: null, + effectiveInputBudgetTokens: null, + maxOutputTokens: null, + remainingTokens: null, + pressurePercent: null, + pressure: 'unknown', + knownProviderBudget: false, + limitSource: 'unknown', + limitConfidence: 'unknown', + limitDiagnostic: 'No context-window metadata is available.', + limitFetchedAt: null, + } + + render( + , + ) + + expect(screen.getByRole('button', { name: /context meter: context unknown/i })).toBeVisible() + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + }) + + it('reports over-budget overflow as a filled danger progress state', () => { + const overBudget: SessionContextBudgetDto = { + ...baseBudget, + remainingTokens: 0, + pressurePercent: 105, + estimatedTokens: 105_000, + pressure: 'over', + } + + render( + , + ) + + expect(screen.getByText('5K over')).toBeVisible() + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100') + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuetext', + '0 percent context remaining for gpt-5.4', + ) + }) + + it('renders an unavailable state when refresh fails before a snapshot exists', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /context meter: context unavailable/i })).toBeVisible() + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + }) +}) diff --git a/client/components/xero/agent-runtime/agent-context-meter.tsx b/client/components/xero/agent-runtime/agent-context-meter.tsx new file mode 100644 index 00000000..afec154a --- /dev/null +++ b/client/components/xero/agent-runtime/agent-context-meter.tsx @@ -0,0 +1,157 @@ +import { AlertTriangle } from 'lucide-react' + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import type { + SessionContextBudgetDto, + SessionContextSnapshotDto, +} from '@/src/lib/xero-model/session-context' + +export type AgentContextMeterStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error' + +interface AgentContextMeterProps { + status: AgentContextMeterStatus + snapshot: SessionContextSnapshotDto | null + hasUserMessage?: boolean +} + +const RING_RADIUS = 8.5 +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS + +function formatTokens(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) { + return 'Unknown' + } + + if (value >= 1_000_000) { + return `${trimDecimal(value / 1_000_000)}M` + } + + if (value >= 1_000) { + return `${trimDecimal(value / 1_000)}K` + } + + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) +} + +function trimDecimal(value: number): string { + return value.toLocaleString(undefined, { + maximumFractionDigits: value >= 10 ? 0 : 1, + minimumFractionDigits: 0, + }) +} + +function getBudgetLabel(status: AgentContextMeterStatus, budget: SessionContextBudgetDto | null): string { + if (status === 'loading' && !budget) return 'Context' + if (status === 'error' && !budget) return 'Context unavailable' + if (!budget || !budget.knownProviderBudget) return 'Context unknown' + + if (budget.pressure === 'over') { + const overflow = Math.max(0, budget.estimatedTokens - (budget.effectiveInputBudgetTokens ?? 0)) + return `${formatTokens(overflow)} over` + } + + const remaining = budget.remainingTokens ?? 0 + const pressurePercent = Math.min(100, budget.pressurePercent ?? 0) + const remainingPercent = Math.max(0, 100 - pressurePercent) + if (remainingPercent >= 95) return 'Full' + if (remaining < 20_000) return `${formatTokens(remaining)} left` + return `${remainingPercent}% left` +} + +function getRingTone(budget: SessionContextBudgetDto | null, status: AgentContextMeterStatus): string { + if (status === 'error') return 'stroke-destructive text-destructive' + if (!budget?.knownProviderBudget) return 'stroke-muted-foreground/55 text-muted-foreground' + + switch (budget.pressure) { + case 'low': + return 'stroke-primary/65 text-primary' + case 'medium': + return 'stroke-sky-500 text-sky-600 dark:text-sky-400' + case 'high': + return 'stroke-amber-500 text-amber-600 dark:text-amber-400' + case 'over': + return 'stroke-destructive text-destructive' + case 'unknown': + return 'stroke-muted-foreground/55 text-muted-foreground' + } +} + +export function AgentContextMeter({ status, snapshot, hasUserMessage = true }: AgentContextMeterProps) { + const budget = snapshot?.budget ?? null + const knownBudget = Boolean(budget?.knownProviderBudget && budget.pressurePercent != null) + const hideBaselineUsage = knownBudget && !hasUserMessage + const pressure = knownBudget && !hideBaselineUsage ? Math.min(100, budget?.pressurePercent ?? 0) : 0 + const remainingPercent = knownBudget ? Math.max(0, 100 - pressure) : null + const label = hideBaselineUsage ? 'Full' : getBudgetLabel(status, budget) + const ringTone = hideBaselineUsage ? 'stroke-primary/65 text-primary' : getRingTone(budget, status) + const fillOffset = RING_CIRCUMFERENCE * (1 - pressure / 100) + const tooltip = remainingPercent != null ? `${remainingPercent}% remaining` : label + const ariaValueText = knownBudget + ? `${remainingPercent} percent context remaining for ${snapshot?.modelId ?? 'the selected model'}` + : label + + return ( + + + + + {tooltip} + + ) +} diff --git a/client/components/xero/agent-runtime/agent-create-draft-section.test.tsx b/client/components/xero/agent-runtime/agent-create-draft-section.test.tsx new file mode 100644 index 00000000..fcd257fa --- /dev/null +++ b/client/components/xero/agent-runtime/agent-create-draft-section.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { AgentCreateDraftSection } from '@/components/xero/agent-runtime/agent-create-draft-section' +import type { RuntimeStreamToolItemView } from '@/src/lib/xero-model' + +function makeToolItem(overrides: Partial = {}): RuntimeStreamToolItemView { + return { + id: 'tool-1', + runId: 'run-1', + sequence: 1, + createdAt: '2026-05-01T12:00:00Z', + kind: 'tool', + toolCallId: 'tool-call-1', + toolName: 'agent_definition', + toolState: 'succeeded', + detail: 'Drafted agent definition `team_research` for review.', + toolSummary: null, + ...overrides, + } +} + +describe('AgentCreateDraftSection', () => { + it('renders the empty primer when no agent_definition tool calls exist yet', () => { + const onOpen = vi.fn() + render( + , + ) + + expect(screen.getByText(/Describe the agent you want/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Manage agents/i })) + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + it('shows recent agent_definition tool activity and surfaces pending approval count', () => { + render( + , + ) + + expect(screen.getByText('2 pending approvals')).toBeInTheDocument() + expect(screen.getByText(/Drafted agent definition/)).toBeInTheDocument() + expect(screen.getByText(/failed validation/)).toBeInTheDocument() + }) + + it('ignores non agent_definition tool items', () => { + render( + , + ) + + expect(screen.getByText(/Describe the agent you want/i)).toBeInTheDocument() + expect(screen.queryByText('Saved record')).not.toBeInTheDocument() + }) +}) diff --git a/client/components/xero/agent-runtime/agent-create-draft-section.tsx b/client/components/xero/agent-runtime/agent-create-draft-section.tsx new file mode 100644 index 00000000..786ea01e --- /dev/null +++ b/client/components/xero/agent-runtime/agent-create-draft-section.tsx @@ -0,0 +1,135 @@ +import { ArrowRight, FileWarning, Settings, Sparkles } from "lucide-react" + +import type { + RuntimeStreamToolItemView, + RuntimeStreamViewItem, +} from "@/src/lib/xero-model" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +const AGENT_DEFINITION_TOOL_NAME = "agent_definition" + +interface AgentCreateDraftSectionProps { + runtimeStreamItems: readonly RuntimeStreamViewItem[] + pendingApprovalCount: number + onOpenAgentManagement?: () => void +} + +function isAgentDefinitionToolItem(item: RuntimeStreamViewItem): item is RuntimeStreamToolItemView { + return item.kind === "tool" && item.toolName === AGENT_DEFINITION_TOOL_NAME +} + +export function AgentCreateDraftSection({ + runtimeStreamItems, + pendingApprovalCount, + onOpenAgentManagement, +}: AgentCreateDraftSectionProps) { + const recentDraftItems = runtimeStreamItems + .filter(isAgentDefinitionToolItem) + .slice(-4) + .reverse() + + if (recentDraftItems.length === 0) { + return ( + + + + + + Describe the agent you want and Agent Create will draft a definition. Saving requires + explicit approval through the action panel below. + + + + Activated drafts appear in the agent selector and can be archived from settings. + {onOpenAgentManagement ? ( + + ) : null} + + + ) + } + + return ( + + + + + + Recent definition tool calls from this run. Approve a save below to activate the + custom agent. + + + + {recentDraftItems.map((item) => ( + + ))} + {onOpenAgentManagement ? ( + + ) : null} + + + ) +} + +interface DraftRowProps { + item: RuntimeStreamToolItemView +} + +function DraftRow({ item }: DraftRowProps) { + const isFailed = item.toolState === "failed" + return ( +
+
+ + {isFailed ? ( + + + {item.toolState} + +
+ {item.detail ? ( +

{item.detail}

+ ) : null} +
+ ) +} diff --git a/client/components/xero/agent-runtime/checkpoint-control-loop-helpers.ts b/client/components/xero/agent-runtime/checkpoint-control-loop-helpers.ts deleted file mode 100644 index 9429f6a4..00000000 --- a/client/components/xero/agent-runtime/checkpoint-control-loop-helpers.ts +++ /dev/null @@ -1,310 +0,0 @@ -import type { - AgentPaneView, - AgentTrustSnapshotView, -} from '@/src/features/xero/use-xero-desktop-state' -import type { - OperatorApprovalView, - ResumeHistoryEntryView, -} from '@/src/lib/xero-model' - -import { type BadgeVariant, displayValue } from './shared-helpers' - -type CheckpointControlLoopCard = NonNullable['items'][number] -type OperatorIntentKind = 'approve' | 'reject' | 'resume' -type PerActionResumeState = 'waiting' | 'running' | 'started' | 'failed' - -export interface PerActionResumeStateMeta { - state: PerActionResumeState - label: string - detail: string - badgeVariant: BadgeVariant - timestamp: string | null -} - -export function createEmptyCheckpointControlLoop(): NonNullable { - return { - items: [], - totalCount: 0, - visibleCount: 0, - hiddenCount: 0, - isTruncated: false, - windowLabel: 'No checkpoint actions are visible in the bounded control-loop window.', - emptyTitle: 'No checkpoint control loops recorded', - emptyBody: - 'Xero has not observed a live or durable checkpoint boundary for this project yet. Waiting boundaries, resume outcomes, and broker fan-out will appear here once recorded.', - missingEvidenceCount: 0, - liveHintOnlyCount: 0, - durableOnlyCount: 0, - recoveredCount: 0, - } -} - -export function getCheckpointControlLoopTruthBadgeVariant( - truthSource: CheckpointControlLoopCard['truthSource'], -): BadgeVariant { - switch (truthSource) { - case 'live_and_durable': - return 'default' - case 'live_hint_only': - return 'secondary' - case 'durable_only': - case 'recovered_durable': - return 'outline' - } -} - -export function getApprovalBadgeVariant(status: OperatorApprovalView['status']): BadgeVariant { - switch (status) { - case 'pending': - return 'secondary' - case 'approved': - return 'default' - case 'rejected': - return 'destructive' - } -} - -export function getCheckpointControlLoopDurableBadgeVariant(card: CheckpointControlLoopCard): BadgeVariant { - if (card.approval) { - return getApprovalBadgeVariant(card.approval.status) - } - - if (card.liveActionRequired) { - return 'secondary' - } - - return 'outline' -} - -export function getCheckpointControlLoopBrokerBadgeVariant(card: CheckpointControlLoopCard): BadgeVariant { - if (card.brokerAction?.hasFailures) { - return 'destructive' - } - - if (card.brokerAction?.hasPending) { - return 'secondary' - } - - return card.brokerAction ? 'default' : 'outline' -} - -export function getCheckpointControlLoopEvidenceBadgeVariant(card: CheckpointControlLoopCard): BadgeVariant { - return card.evidenceCount > 0 ? 'outline' : 'secondary' -} - -export function getCheckpointControlLoopFailureBadgeVariant(card: CheckpointControlLoopCard): BadgeVariant { - switch (card.advancedFailureClass) { - case 'timeout': - return 'secondary' - case 'policy_permission': - return 'destructive' - case 'validation_runtime': - return 'outline' - default: - return 'outline' - } -} - -export function getCheckpointControlLoopResumabilityBadgeVariant(card: CheckpointControlLoopCard): BadgeVariant { - switch (card.resumability) { - case 'resumable': - return 'default' - case 'awaiting_approval': - return 'secondary' - case 'not_resumable': - return 'destructive' - case 'unknown': - return 'outline' - default: - return 'outline' - } -} - -export function getCheckpointControlLoopRecoveryBadgeVariant(card: CheckpointControlLoopCard): BadgeVariant { - switch (card.recoveryRecommendation) { - case 'approve_resume': - return 'default' - case 'retry': - return 'secondary' - case 'fix_permissions_policy': - return 'destructive' - case 'observe': - return 'outline' - default: - return 'outline' - } -} - -export function getCheckpointControlLoopRecoveryAlertMeta(options: { - controlLoop: NonNullable - trustSnapshot: Pick - autonomousRunErrorMessage: string | null | undefined - notificationSyncPollingActive: boolean - notificationSyncPollingActionId: string | null - notificationSyncPollingBoundaryId: string | null -}) { - if (options.controlLoop.items.length === 0) { - return null - } - - if (options.notificationSyncPollingActive && options.trustSnapshot.syncState === 'degraded') { - return { - title: 'Showing last truthful checkpoint loop', - body: `Xero is still polling remote routes for blocked boundary ${displayValue(options.notificationSyncPollingBoundaryId, 'unknown')} and action ${displayValue(options.notificationSyncPollingActionId, 'unknown')} while preserving the last truthful sync summary. ${options.trustSnapshot.syncReason}`, - variant: 'destructive' as const, - } - } - - if (options.trustSnapshot.syncState === 'degraded') { - return { - title: 'Showing last truthful checkpoint loop', - body: options.trustSnapshot.syncReason, - variant: 'destructive' as const, - } - } - - if (options.autonomousRunErrorMessage) { - return { - title: 'Recovered checkpoint state remains visible', - body: options.autonomousRunErrorMessage, - variant: 'default' as const, - } - } - - if (options.notificationSyncPollingActive) { - return { - title: 'Remote escalation is actively polling this checkpoint', - body: `Xero is polling remote routes for blocked boundary ${displayValue(options.notificationSyncPollingBoundaryId, 'unknown')} and action ${displayValue(options.notificationSyncPollingActionId, 'unknown')} while durable approval, broker, and resume truth remain visible here.`, - variant: 'default' as const, - } - } - - return null -} - -export function getCheckpointControlLoopCoverageAlertMeta( - controlLoop: NonNullable, -) { - if (controlLoop.items.length === 0) { - return null - } - - const coverageNotes: string[] = [] - if (controlLoop.isTruncated) { - coverageNotes.push(`${controlLoop.hiddenCount} older checkpoint action${controlLoop.hiddenCount === 1 ? '' : 's'} are outside this bounded window.`) - } - if (controlLoop.liveHintOnlyCount > 0) { - coverageNotes.push( - controlLoop.liveHintOnlyCount === 1 - ? '1 card is still anchored to live hints while durable rows persist.' - : `${controlLoop.liveHintOnlyCount} cards are still anchored to live hints while durable rows persist.`, - ) - } - if (controlLoop.missingEvidenceCount > 0) { - coverageNotes.push( - controlLoop.missingEvidenceCount === 1 - ? '1 card still lacks durable evidence inside the bounded artifact window.' - : `${controlLoop.missingEvidenceCount} cards still lack durable evidence inside the bounded artifact window.`, - ) - } - if (controlLoop.recoveredCount > 0) { - coverageNotes.push( - controlLoop.recoveredCount === 1 - ? '1 card is being shown from recovered durable history after the live row cleared.' - : `${controlLoop.recoveredCount} cards are being shown from recovered durable history after the live row cleared.`, - ) - } - - if (coverageNotes.length === 0) { - return null - } - - return { - title: 'Bounded checkpoint coverage', - body: coverageNotes.join(' '), - } -} - -function getResumeBadgeVariant(status: ResumeHistoryEntryView['status']): BadgeVariant { - switch (status) { - case 'started': - return 'default' - case 'failed': - return 'destructive' - } -} - -export function getPerActionResumeStateMeta(options: { - card: CheckpointControlLoopCard - operatorActionStatus: AgentPaneView['operatorActionStatus'] - pendingOperatorActionId: string | null - pendingOperatorIntent: { actionId: string; kind: OperatorIntentKind } | null -}): PerActionResumeStateMeta { - const { card, operatorActionStatus, pendingOperatorActionId, pendingOperatorIntent } = options - const approval = card.approval - const latestResumeForAction = card.latestResume - const isActionInFlight = - (operatorActionStatus === 'running' && pendingOperatorActionId === card.actionId) || - pendingOperatorIntent?.actionId === card.actionId - - if (isActionInFlight) { - return { - state: 'running', - label: 'Running', - detail: - pendingOperatorIntent?.kind === 'resume' - ? 'Resume request is in flight for this action. Xero will refresh durable state before updating this card.' - : 'Decision persistence is in flight for this action. Xero keeps the last durable resume state visible until refresh completes.', - badgeVariant: 'secondary', - timestamp: approval?.updatedAt ?? card.resumeUpdatedAt, - } - } - - if (latestResumeForAction?.status === 'failed') { - return { - state: 'failed', - label: 'Failed', - detail: `Latest resume failed: ${displayValue(latestResumeForAction.summary, 'Resume failed for this action.')}`, - badgeVariant: 'destructive', - timestamp: latestResumeForAction.createdAt, - } - } - - if (latestResumeForAction?.status === 'started') { - return { - state: 'started', - label: 'Started', - detail: `Latest resume started: ${displayValue(latestResumeForAction.summary, 'Resume started for this action.')}`, - badgeVariant: getResumeBadgeVariant(latestResumeForAction.status), - timestamp: latestResumeForAction.createdAt, - } - } - - if (approval?.isPending) { - return { - state: 'waiting', - label: 'Waiting', - detail: 'Waiting for operator input before this action can resume the run.', - badgeVariant: 'outline', - timestamp: approval.updatedAt, - } - } - - if (approval?.canResume) { - return { - state: 'waiting', - label: 'Waiting', - detail: 'No resume recorded yet for this action.', - badgeVariant: 'outline', - timestamp: approval.updatedAt, - } - } - - return { - state: 'waiting', - label: card.resumeStateLabel, - detail: card.resumeDetail, - badgeVariant: 'outline', - timestamp: card.resumeUpdatedAt, - } -} diff --git a/client/components/xero/agent-runtime/checkpoint-control-loop-section.tsx b/client/components/xero/agent-runtime/checkpoint-control-loop-section.tsx deleted file mode 100644 index eb7c834a..00000000 --- a/client/components/xero/agent-runtime/checkpoint-control-loop-section.tsx +++ /dev/null @@ -1,519 +0,0 @@ -import { AlertCircle, LoaderCircle, ShieldCheck } from 'lucide-react' - -import type { AgentPaneView } from '@/src/features/xero/use-xero-desktop-state' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' - -import { - getCheckpointControlLoopBrokerBadgeVariant, - getCheckpointControlLoopCoverageAlertMeta, - getCheckpointControlLoopDurableBadgeVariant, - getCheckpointControlLoopEvidenceBadgeVariant, - getCheckpointControlLoopFailureBadgeVariant, - getCheckpointControlLoopRecoveryAlertMeta, - getCheckpointControlLoopRecoveryBadgeVariant, - getCheckpointControlLoopResumabilityBadgeVariant, - getCheckpointControlLoopTruthBadgeVariant, - getPerActionResumeStateMeta, -} from './checkpoint-control-loop-helpers' -import { displayValue, formatTimestamp } from './shared-helpers' -import type { PendingOperatorIntent } from './use-agent-runtime-controller' - -type CheckpointControlLoop = NonNullable - -type CheckpointControlLoopCard = CheckpointControlLoop['items'][number] - -interface CheckpointControlLoopSectionProps { - checkpointControlLoop: CheckpointControlLoop - pendingApprovalCount: number - operatorActionError: AgentPaneView['operatorActionError'] - operatorActionStatus: AgentPaneView['operatorActionStatus'] - pendingOperatorActionId: string | null - pendingOperatorIntent: PendingOperatorIntent | null - operatorAnswers: Record - checkpointControlLoopRecoveryAlert: ReturnType - checkpointControlLoopCoverageAlert: ReturnType - onOperatorAnswerChange: (actionId: string, value: string) => void - onResolveOperatorAction: ( - actionId: string, - decision: 'approve' | 'reject', - options?: { userAnswer?: string | null }, - ) => Promise - onResumeOperatorRun: (actionId: string, options?: { userAnswer?: string | null }) => Promise - onResumeLiveActionRequired?: (actionId: string, options?: { userAnswer?: string | null }) => Promise -} - -function normalizeAnswerInput(value: string): string { - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : '' -} - -function isOperatorActionPending(options: { - actionId: string - operatorActionStatus: AgentPaneView['operatorActionStatus'] - pendingOperatorActionId: string | null - pendingOperatorIntent: PendingOperatorIntent | null -}): boolean { - return ( - options.pendingOperatorIntent?.actionId === options.actionId || - (options.operatorActionStatus === 'running' && options.pendingOperatorActionId === options.actionId) - ) -} - -export function CheckpointControlLoopSection({ - checkpointControlLoop, - pendingApprovalCount, - operatorActionError, - operatorActionStatus, - pendingOperatorActionId, - pendingOperatorIntent, - operatorAnswers, - checkpointControlLoopRecoveryAlert, - checkpointControlLoopCoverageAlert, - onOperatorAnswerChange, - onResolveOperatorAction, - onResumeOperatorRun, - onResumeLiveActionRequired, -}: CheckpointControlLoopSectionProps) { - return ( -
-
-
-
-

- Operator checkpoints -

-

Checkpoint control loop

-
-
- 0 ? 'secondary' : 'outline'}>{pendingApprovalCount} pending - {checkpointControlLoop.windowLabel} - {checkpointControlLoop.isTruncated ? ( - - +{checkpointControlLoop.hiddenCount} older action{checkpointControlLoop.hiddenCount === 1 ? '' : 's'} - - ) : null} -
-
- -

- Xero correlates live action-required hints with durable approvals, broker fan-out, resume history, and - bounded evidence so the same action and boundary stay traceable from pause to recovery. -

- - {operatorActionError ? ( - - - Operator action failed - -

{operatorActionError.message}

-

code: {operatorActionError.code}

-
-
- ) : null} - - {checkpointControlLoopRecoveryAlert ? ( - - {checkpointControlLoopRecoveryAlert.variant === 'destructive' ? ( - - ) : ( - - )} - {checkpointControlLoopRecoveryAlert.title} - {checkpointControlLoopRecoveryAlert.body} - - ) : null} - - {checkpointControlLoopCoverageAlert ? ( - - - {checkpointControlLoopCoverageAlert.title} - {checkpointControlLoopCoverageAlert.body} - - ) : null} - - {checkpointControlLoop.items.length > 0 ? ( -
- {checkpointControlLoop.items.map((card) => ( - - ))} -
- ) : ( - - )} -
-
- ) -} - -function CheckpointControlLoopCardView({ - card, - operatorActionStatus, - pendingOperatorActionId, - pendingOperatorIntent, - answerValue, - onOperatorAnswerChange, - onResolveOperatorAction, - onResumeOperatorRun, - onResumeLiveActionRequired, -}: { - card: CheckpointControlLoopCard - operatorActionStatus: AgentPaneView['operatorActionStatus'] - pendingOperatorActionId: string | null - pendingOperatorIntent: PendingOperatorIntent | null - answerValue: string - onOperatorAnswerChange: (actionId: string, value: string) => void - onResolveOperatorAction: ( - actionId: string, - decision: 'approve' | 'reject', - options?: { userAnswer?: string | null }, - ) => Promise - onResumeOperatorRun: (actionId: string, options?: { userAnswer?: string | null }) => Promise - onResumeLiveActionRequired?: (actionId: string, options?: { userAnswer?: string | null }) => Promise -}) { - const approval = card.approval - const normalizedAnswer = normalizeAnswerInput(answerValue) - const requiresAnswer = approval?.requiresUserAnswer ?? false - const showAnswerError = Boolean(approval) && requiresAnswer && answerValue.length > 0 && normalizedAnswer.length === 0 - const canAnswerOwnedLiveAction = Boolean( - !approval && card.liveActionRequired?.boundaryId === 'owned_agent' && onResumeLiveActionRequired, - ) - const showOwnedLiveActionAnswerError = - canAnswerOwnedLiveAction && answerValue.length > 0 && normalizedAnswer.length === 0 - const actionPending = isOperatorActionPending({ - actionId: card.actionId, - operatorActionStatus, - pendingOperatorActionId, - pendingOperatorIntent, - }) - const resumeMeta = getPerActionResumeStateMeta({ - card, - operatorActionStatus, - pendingOperatorActionId, - pendingOperatorIntent, - }) - - return ( -
-
-
-
-

{card.title}

- {card.truthSourceLabel} - {card.durableStateLabel} - {card.advancedFailureClassLabel ? ( - - {card.advancedFailureClassLabel} - - ) : null} - - {displayValue(card.resumabilityLabel, 'Resumability unknown')} - - - {displayValue(card.recoveryRecommendationLabel, 'Observe durable state')} - - {resumeMeta.label} -
-

{card.detail}

-

- Action {card.actionId} · Boundary {displayValue(card.boundaryId, 'Pending durable linkage')} -

-

{card.truthSourceDetail}

-

- Failure class {displayValue(card.advancedFailureClassLabel, 'Not classified')} - {card.advancedFailureDiagnosticCode ? ` · ${card.advancedFailureDiagnosticCode}` : ''} -

-

- Resumability {displayValue(card.resumabilityLabel, 'Resumability unknown')} ·{' '} - {displayValue(card.resumabilityDetail, 'Xero has not observed enough durable approval or resume evidence yet.')} -

-

- Recovery guidance {displayValue(card.recoveryRecommendationLabel, 'Observe durable state')} ·{' '} - {displayValue( - card.recoveryRecommendationDetail, - 'No typed advanced failure metadata is available yet. Keep the current durable state visible and wait for canonical evidence before retrying.', - )} -

-
-
- -
-
-
-

Live

- {card.liveStateLabel} -
-

{card.liveStateDetail}

-

Updated {formatTimestamp(card.liveUpdatedAt)}

-
- -
-
-

Resume

- {resumeMeta.label} -
-

{resumeMeta.detail}

-

Updated {formatTimestamp(resumeMeta.timestamp)}

-
- -
-
-

Broker

- {card.brokerStateLabel} -
-

{card.brokerStateDetail}

-

Updated {formatTimestamp(card.brokerLatestUpdatedAt)}

- {card.brokerRoutePreviews.length > 0 ? ( -
    - {card.brokerRoutePreviews.map((route) => ( -
  • -
    - {route.routeId} - {route.statusLabel} -
    -

    {route.detail}

    -
  • - ))} -
- ) : null} -
- -
-
-

Evidence

- {card.evidenceStateLabel} -
-

{card.evidenceSummary}

-

Latest evidence {formatTimestamp(card.latestEvidenceAt)}

- {card.evidencePreviews.length > 0 ? ( -
    - {card.evidencePreviews.map((artifact) => ( -
  • -
    - {artifact.artifactKindLabel} - {artifact.statusLabel} -
    -

    {artifact.summary}

    -
  • - ))} -
- ) : null} -
-
- - {approval ? ( -
-
-
-

- {requiresAnswer ? 'Required answer contract' : 'Optional answer contract'} -

-

- Answer shape: {approval.answerShapeLabel} -

- {approval.answerShapeHint ? ( -

{approval.answerShapeHint}

- ) : null} -
- - {approval.isPending || approval.canResume ? ( -