From 281630434c0bd350e0ccfeeed7579bcbf423773d Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 27 Jul 2026 15:46:40 -0700 Subject: [PATCH] Harden Iris tutorial capture and Frame exploration --- captutor/README.md | 92 ++- captutor/assets/fuser-metaballs-0.png | Bin 0 -> 57094 bytes captutor/assets/fuser-metaballs-1.png | Bin 0 -> 58404 bytes captutor/assets/fuser-metaballs-2.png | Bin 0 -> 58006 bytes captutor/bin/captutor-cursor.swift | 328 ++++++++++ captutor/bin/captutor-wallpaper.swift | 576 ++++++++++++++++- captutor/bin/cdp-frame.mjs | 52 ++ captutor/bin/install.sh | 3 + captutor/bin/render-fuser-metaballs.swift | 189 ++++++ captutor/bin/stage.mjs | 51 +- captutor/captutor.mjs | 137 +++- captutor/lib/cdp.mjs | 255 +++++++- captutor/lib/cursor.mjs | 254 ++++---- captutor/lib/onboarding.mjs | 149 +++++ captutor/lib/stage-contract.mjs | 52 ++ captutor/lib/stage-mode.mjs | 131 +++- captutor/lib/tutorial-layout.mjs | 272 ++++++++ captutor/ops/heartbeat.sh | 36 ++ captutor/ops/iris-heartbeat-recovery.mjs | 201 ++++++ captutor/ops/iris-orchestrator.mjs | 587 ++++++++++++++++++ captutor/ops/iris-progress-publisher.mjs | 177 ++++++ captutor/screenplays/talking-taco.mjs | 491 +++++++++++++++ captutor/test/cdp-health.test.mjs | 93 +++ captutor/test/cursor.test.mjs | 35 ++ .../test/iris-heartbeat-recovery.test.mjs | 41 ++ captutor/test/stage-contract.test.mjs | 61 ++ captutor/test/tutorial-layout.test.mjs | 47 ++ slab/bin/frame-mcp.mjs | 115 +++- slab/lib/frame-hover-atlas.mjs | 104 ++++ slab/test/frame-hover-atlas.test.mjs | 35 ++ 30 files changed, 4381 insertions(+), 183 deletions(-) create mode 100644 captutor/assets/fuser-metaballs-0.png create mode 100644 captutor/assets/fuser-metaballs-1.png create mode 100644 captutor/assets/fuser-metaballs-2.png create mode 100644 captutor/bin/captutor-cursor.swift create mode 100755 captutor/bin/cdp-frame.mjs create mode 100644 captutor/bin/render-fuser-metaballs.swift create mode 100644 captutor/lib/onboarding.mjs create mode 100644 captutor/lib/stage-contract.mjs create mode 100644 captutor/lib/tutorial-layout.mjs create mode 100644 captutor/ops/heartbeat.sh create mode 100644 captutor/ops/iris-heartbeat-recovery.mjs create mode 100644 captutor/ops/iris-orchestrator.mjs create mode 100644 captutor/ops/iris-progress-publisher.mjs create mode 100644 captutor/screenplays/talking-taco.mjs create mode 100644 captutor/test/cdp-health.test.mjs create mode 100644 captutor/test/cursor.test.mjs create mode 100644 captutor/test/iris-heartbeat-recovery.test.mjs create mode 100644 captutor/test/stage-contract.test.mjs create mode 100644 captutor/test/tutorial-layout.test.mjs create mode 100644 slab/lib/frame-hover-atlas.mjs create mode 100644 slab/test/frame-hover-atlas.test.mjs diff --git a/captutor/README.md b/captutor/README.md index 60c1b1c27..7fe0cf19d 100644 --- a/captutor/README.md +++ b/captutor/README.md @@ -115,10 +115,12 @@ driving are separable**; only the capture changed. CDP clicks do not move the macOS cursor, and `reel` films the real window — so without coordination the video would show a dead pointer parked in a corner while -buttons depressed by themselves. `bin/stage.mjs` is the production path: it films -the real 1.5× macOS pointer and moves it smoothly to the exact same coordinate as -each trusted CDP click. The older shadow-DOM tutorial pointer remains available -outside Stage Mode. +buttons depressed by themselves. `bin/stage.mjs` is the production path: it +compiles a transparent, click-through Swift cursor overlay and moves its exact +arrow-tip hotspot to the same coordinate as each trusted CDP click. The native +overlay eases at 120 Hz, leaves a restrained particle trail, and emits a small +click burst. It is capture-visible but never participates in browser hit-testing; +`CAPTUTOR_REAL_CURSOR=1` remains available for explicitly human-driven takes. Stage Mode is a reversible transaction around any Captutor command. It saves the current desk, closes stale QuickTime previews, switches macOS to Light appearance @@ -126,17 +128,38 @@ and the display to 2× HiDPI (1280×720 logical), centers the browser, raises encoding quality, uses a branded light wallpaper, and temporarily hides desktop icons, Dock, menu bar, Stats, Macpal's desktop badge, and Slab prompt sigils. The recorder captures the complete physical desktop, preserving the real rounded -window, shadow, and equal margin. A Swift desktop-level renderer adds a subtle, -deterministic field of rising icon-only production Fuser SVG marks. The -wallpaper contains no wordmark or decorative color field: it renders only the -mark, black-on-white or white-on-black from the active macOS appearance. It is -click-through, runs behind every normal window, and exits inside the same Stage -transaction. Delivery changes only the tiny +window, shadow, and equal margin. A Swift desktop-level renderer supplies the +selected client backdrop. It is click-through, runs behind every normal window, +and exits inside the same Stage transaction. Delivery changes only the tiny ScreenCaptureKit status dot in the extreme top-right, using live pixels sampled from the adjacent desktop; it never masks or crops the browser window. -Its `finally` handler restores the saved display mode, pointer size, wallpaper, +Its `finally` handler restores the saved display mode, optional real-pointer size, wallpaper, processes, and desktop preferences on success, failure, or interruption. +Stage backdrops are selected per brand. `fuser` is currently the default and +uses the twelve node positions from the production `fuser-mark.svg` as one +connected glossy metaball sculpture over a quiet black-and-white field. Obsidian, +pearl, and graphite variants stay inside Fuser's monochrome brand system. One +instanced Metal pass raymarches the live smooth-union field at the display's +native backing resolution; each logo tumbles independently through yaw, pitch, +and roll. Smaller background marks use a softer, lower-step depth-of-field +treatment to preserve the power budget. There is no sprite +stepping, opacity pulse, wordmark, or generated approximation. The former +monochrome rising-mark field remains available as `classic`. +The ambient drift reverses gently inside a per-logo safe inset, so even the +largest rotating metaball volume remains fully on-screen without edge fades. + +Regenerate the three palette stills after changing the implicit surface: + +```bash +swift captutor/bin/render-fuser-metaballs.swift captutor/assets +``` + +```bash +node bin/stage.mjs --brand fuser render +node bin/stage.mjs --brand classic render +``` + ## Staying signed in, and not spending the client's money Two things have to be true before a take, and neither can wait for a human: @@ -191,6 +214,36 @@ the page so the session cookie rides along. (Its REST twin at ## Running it +### Pathfinding preflight (invisible CDP frame) + +Before authoring selectors, take one structured internal frame of the real +Fuser tab: + +```sh +CDP_PORT=9333 node bin/cdp-frame.mjs --match fuser.studio +CDP_PORT=9333 node bin/cdp-frame.mjs --match fuser.studio \ + --screenshot /tmp/fuser-preflight.png +``` + +The JSON reports the viewport, focus, visible controls with locator candidates, +and React Flow nodes, handles, and edges. The optional screenshot comes from +`Page.captureScreenshot`: it is read directly from Chrome's compositor and +never draws Frame, OCR, target, cursor, or Puppet UI on the filming display. + +Use this command for exploratory preflight instead of one-off `node -e` scripts +that call `attach()` and forget to close the socket. Programmatic probes should +use `withSession()` from `lib/cdp.mjs`, or close a directly owned session in a +`finally` block. The CLI has a 15-second hard ceiling and always closes CDP, so +pathfinding cannot silently turn into repeated 120-second tool timeouts. + +Captutor also enables Chrome's renderer-crash event before setup and runs a +bounded page heartbeat immediately before recording and throughout every take. +Chrome can leave an "Aw, Snap!" target in `/json` with its old Fuser title and +URL, so target metadata alone is never treated as proof of health. A crash stops +the reel, preserves an `aborted-browser-*.mp4`, and records +`browser-renderer-crash` in `out/failures.ndjson` instead of leaving the mission +apparently in progress. + `reel` needs SlabMenubar running with the Screen Recording grant (`node slab/bin/frame.mjs doctor`). @@ -278,6 +331,23 @@ Deriving the video from the page is what keeps the two from drifting apart. one is authored by hand (`screenplays/smoke.mjs` is its skeleton), and the page and the video get written together. +### Fuser onboarding contract + +Tutorial capture must not depend on translated button copy, generated classes, +or the portal structure of a tooltip. Fuser exposes active tutorial state through +semantic DOM attributes: + +- `data-onboarding-step` and `data-onboarding-active` mark the product target; +- `data-onboarding-overlay` and `data-onboarding-content-index` identify the card; +- `data-onboarding-requirement` reports a gated interaction; +- `data-onboarding-action` names Previous, Next, Finish, Skip, and Replay controls. + +`lib/onboarding.mjs` reads this contract, waits for an exact step, and advances +the walkthrough. On localhost it can satisfy manual tutorial requirements through +Fuser's narrow `window.__fuserOnboardingAudit` bridge, allowing a non-billable +replay audit without reaching into Zustand or executing a model. The bridge is +absent in production. + ## Known gaps - **A Chrome relaunched over ssh films badly.** On panda, `bin/film-chrome.sh` diff --git a/captutor/assets/fuser-metaballs-0.png b/captutor/assets/fuser-metaballs-0.png new file mode 100644 index 0000000000000000000000000000000000000000..7bb711576779d54eb25a88b320a93d78ff1f0c81 GIT binary patch literal 57094 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 N!PC{xWt~$(696hHwf+DA literal 0 HcmV?d00001 diff --git a/captutor/bin/captutor-cursor.swift b/captutor/bin/captutor-cursor.swift new file mode 100644 index 000000000..3ba1ca62e --- /dev/null +++ b/captutor/bin/captutor-cursor.swift @@ -0,0 +1,328 @@ +import AppKit +import CoreGraphics +import Foundation + +// Captutor's filmed pointer. Browser input remains in CDP; this process only +// paints a native, click-through overlay at the same global screen coordinate. + +private struct Command: Decodable { + let op: String + let x: CGFloat? + let y: CGFloat? + let durationMs: Double? +} + +private struct Particle { + var position: CGPoint + var velocity: CGVector + var bornAt: TimeInterval + var lifetime: TimeInterval + var radius: CGFloat + var color: NSColor +} + +private final class CursorView: NSView { + var pointer = CGPoint.zero + var isPointerVisible = false + var isPressed = false + var particles: [Particle] = [] + + override var isFlipped: Bool { true } + override var isOpaque: Bool { false } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.clear(bounds) + + let now = ProcessInfo.processInfo.systemUptime + for particle in particles { + let age = max(0, now - particle.bornAt) + let progress = min(1, age / particle.lifetime) + let alpha = CGFloat(pow(1 - progress, 1.65)) + let center = CGPoint( + x: particle.position.x + particle.velocity.dx * age, + y: particle.position.y + particle.velocity.dy * age + CGFloat(age * age) * 22 + ) + context.setFillColor(particle.color.withAlphaComponent(alpha * 0.82).cgColor) + context.addEllipse(in: CGRect( + x: center.x - particle.radius, + y: center.y - particle.radius, + width: particle.radius * 2, + height: particle.radius * 2 + )) + context.fillPath() + } + + guard isPointerVisible else { return } + + // The path begins at (0, 0): pointer is the actual click hotspot, not + // the centre of an oversized cursor box. Its proportions follow the + // familiar macOS arrow while the dark face and light rim read cleanly + // on both Fuser's pale canvas and its darker controls. + context.saveGState() + context.translateBy(x: pointer.x, y: pointer.y) + let pressedScale: CGFloat = isPressed ? 0.92 : 1 + context.scaleBy(x: pressedScale, y: pressedScale) + + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: 1.6, y: 22.8)) + path.addLine(to: CGPoint(x: 7.3, y: 17.1)) + path.addLine(to: CGPoint(x: 12.0, y: 27.0)) + path.addLine(to: CGPoint(x: 17.0, y: 24.6)) + path.addLine(to: CGPoint(x: 12.3, y: 15.0)) + path.addLine(to: CGPoint(x: 20.4, y: 14.2)) + path.closeSubpath() + + context.setShadow(offset: CGSize(width: 0.7, height: 1.4), blur: 2.4, + color: NSColor.black.withAlphaComponent(0.55).cgColor) + context.setFillColor(NSColor(calibratedWhite: 0.055, alpha: 1).cgColor) + context.addPath(path) + context.fillPath() + context.setShadow(offset: .zero, blur: 0, color: nil) + context.setStrokeColor(NSColor(calibratedWhite: 0.98, alpha: 0.98).cgColor) + context.setLineWidth(2.25) + context.setLineJoin(.round) + context.addPath(path) + context.strokePath() + + // A restrained cool hairline gives the familiar pointer a little life + // without turning it into an annotation or obscuring the target. + context.setStrokeColor(NSColor(calibratedRed: 0.42, green: 0.78, blue: 1, alpha: 0.72).cgColor) + context.setLineWidth(0.62) + context.addPath(path) + context.strokePath() + context.restoreGState() + } +} + +private struct DisplaySurface { + let displayID: CGDirectDisplayID + let quartzFrame: CGRect + let window: NSWindow + let view: CursorView +} + +private final class CursorController { + private var surfaces: [DisplaySurface] = [] + private var activeSurface: Int? + private var currentGlobal: CGPoint? + private var moveStartGlobal = CGPoint.zero + private var moveTargetGlobal = CGPoint.zero + private var moveStartedAt: TimeInterval = 0 + private var moveDuration: TimeInterval = 0 + private var lastTrailGlobal: CGPoint? + private var timer: Timer? + + init() { + rebuildSurfaces() + timer = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] _ in + self?.tick() + } + RunLoop.main.add(timer!, forMode: .common) + } + + deinit { timer?.invalidate() } + + private func rebuildSurfaces() { + for screen in NSScreen.screens { + guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + continue + } + let displayID = CGDirectDisplayID(number.uint32Value) + let window = NSWindow( + contentRect: screen.frame, + styleMask: .borderless, + backing: .buffered, + defer: false, + screen: screen + ) + let view = CursorView(frame: CGRect(origin: .zero, size: screen.frame.size)) + window.contentView = view + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = false + window.ignoresMouseEvents = true + // Borderless accessory windows otherwise default to sharingState + // 0 and disappear from ScreenCaptureKit. The cursor is presentation + // UI, so make its pixels explicitly capture-visible while keeping + // the surface click-through and absent from window cycling. + window.sharingType = .readOnly + window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.statusWindow)) + 1) + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + window.isReleasedWhenClosed = false + surfaces.append(DisplaySurface( + displayID: displayID, + quartzFrame: CGDisplayBounds(displayID), + window: window, + view: view + )) + } + } + + private func surfaceIndex(containing point: CGPoint) -> Int? { + surfaces.firstIndex(where: { $0.quartzFrame.contains(point) }) ?? surfaces.indices.first + } + + private func localPoint(_ global: CGPoint, on surface: DisplaySurface) -> CGPoint { + // CGDisplayBounds and the command protocol both use a top-left origin. + // CursorView is flipped, so subtracting the display origin preserves the + // exact coordinate, including on a secondary display. + CGPoint(x: global.x - surface.quartzFrame.minX, + y: global.y - surface.quartzFrame.minY) + } + + private func setGlobalPoint(_ global: CGPoint, emitTrail: Bool) { + guard let index = surfaceIndex(containing: global) else { return } + if activeSurface != index { + if let old = activeSurface { + surfaces[old].view.isPointerVisible = false + surfaces[old].view.needsDisplay = true + surfaces[old].window.orderOut(nil) + } + activeSurface = index + surfaces[index].window.orderFrontRegardless() + } + + let surface = surfaces[index] + let local = localPoint(global, on: surface) + surface.view.pointer = local + surface.view.isPointerVisible = true + currentGlobal = global + + if emitTrail { + let distance = lastTrailGlobal.map { hypot(global.x - $0.x, global.y - $0.y) } ?? 100 + if distance >= 13 { + emitParticle(at: local, in: surface.view, burst: false) + lastTrailGlobal = global + } + } + surface.view.needsDisplay = true + } + + func move(to target: CGPoint, durationMs: Double) { + let fallback = surfaces.first.map { + CGPoint(x: $0.quartzFrame.midX, y: $0.quartzFrame.midY) + } ?? target + moveStartGlobal = currentGlobal ?? fallback + moveTargetGlobal = target + moveStartedAt = ProcessInfo.processInfo.systemUptime + moveDuration = max(0, durationMs / 1000) + if currentGlobal == nil { setGlobalPoint(moveStartGlobal, emitTrail: false) } + if moveDuration == 0 { setGlobalPoint(target, emitTrail: true) } + } + + func setPressed(_ pressed: Bool) { + guard let index = activeSurface else { return } + surfaces[index].view.isPressed = pressed + surfaces[index].view.needsDisplay = true + } + + func click() { + guard let index = activeSurface else { return } + let surface = surfaces[index] + for _ in 0..<9 { emitParticle(at: surface.view.pointer, in: surface.view, burst: true) } + surface.view.needsDisplay = true + } + + func hide() { + for surface in surfaces { + surface.view.isPointerVisible = false + surface.view.particles.removeAll() + surface.view.needsDisplay = true + surface.window.orderOut(nil) + } + activeSurface = nil + currentGlobal = nil + lastTrailGlobal = nil + } + + private func emitParticle(at point: CGPoint, in view: CursorView, burst: Bool) { + let palette = [ + NSColor(calibratedRed: 0.43, green: 0.82, blue: 1.00, alpha: 1), + NSColor(calibratedRed: 0.73, green: 0.61, blue: 1.00, alpha: 1), + NSColor(calibratedRed: 1.00, green: 0.77, blue: 0.42, alpha: 1), + ] + let angle = CGFloat.random(in: burst ? 0...(2 * .pi) : (0.35 * .pi)...(0.78 * .pi)) + let speed = CGFloat.random(in: burst ? 24...68 : 8...22) + let origin = CGPoint( + x: point.x + CGFloat.random(in: burst ? -2...3 : 2...7), + y: point.y + CGFloat.random(in: burst ? -2...3 : 8...18) + ) + view.particles.append(Particle( + position: origin, + velocity: CGVector(dx: cos(angle) * speed, dy: sin(angle) * speed), + bornAt: ProcessInfo.processInfo.systemUptime, + lifetime: Double.random(in: burst ? 0.34...0.52 : 0.22...0.34), + radius: CGFloat.random(in: burst ? 1.35...2.35 : 0.8...1.45), + color: palette.randomElement()! + )) + } + + private func tick() { + let now = ProcessInfo.processInfo.systemUptime + if moveDuration > 0, now < moveStartedAt + moveDuration { + let t = min(1, max(0, (now - moveStartedAt) / moveDuration)) + let eased = t < 0.5 + ? 4 * t * t * t + : 1 - pow(-2 * t + 2, 3) / 2 + setGlobalPoint(CGPoint( + x: moveStartGlobal.x + (moveTargetGlobal.x - moveStartGlobal.x) * eased, + y: moveStartGlobal.y + (moveTargetGlobal.y - moveStartGlobal.y) * eased + ), emitTrail: true) + } else if moveDuration > 0 { + moveDuration = 0 + setGlobalPoint(moveTargetGlobal, emitTrail: true) + } + + for surface in surfaces { + let oldCount = surface.view.particles.count + surface.view.particles.removeAll { now - $0.bornAt >= $0.lifetime } + if oldCount != surface.view.particles.count || !surface.view.particles.isEmpty { + surface.view.needsDisplay = true + } + } + } +} + +private final class AppDelegate: NSObject, NSApplicationDelegate { + private var controller: CursorController? + + func applicationDidFinishLaunching(_ notification: Notification) { + controller = CursorController() + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + while let line = readLine() { + guard let data = line.data(using: .utf8), + let command = try? JSONDecoder().decode(Command.self, from: data) else { continue } + DispatchQueue.main.async { self?.handle(command) } + } + DispatchQueue.main.async { + self?.controller?.hide() + NSApp.terminate(nil) + } + } + FileHandle.standardOutput.write(Data("ready\n".utf8)) + } + + private func handle(_ command: Command) { + switch command.op { + case "move": + guard let x = command.x, let y = command.y else { return } + controller?.move(to: CGPoint(x: x, y: y), durationMs: command.durationMs ?? 0) + case "down": controller?.setPressed(true) + case "up": controller?.setPressed(false) + case "click": controller?.click() + case "hide": controller?.hide() + case "quit": + controller?.hide() + NSApp.terminate(nil) + default: break + } + } +} + +private let app = NSApplication.shared +app.setActivationPolicy(.accessory) +private let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/captutor/bin/captutor-wallpaper.swift b/captutor/bin/captutor-wallpaper.swift index e4aa73491..8794597a0 100644 --- a/captutor/bin/captutor-wallpaper.swift +++ b/captutor/bin/captutor-wallpaper.swift @@ -2,6 +2,8 @@ import AppKit import CoreGraphics +import Metal +import QuartzCore private struct Particle { let x: CGFloat @@ -133,8 +135,11 @@ private final class WallpaperView: NSView { (dark ? NSColor.black : NSColor.white).setFill() rect.fill() - if card.phase == "ambient" { - for particle in field { + // Keep the drifting Fuser marks as the shared visual system behind + // every phase. Cards soften that field and add type; they never place + // a separate hero logo in front of the title. + let fieldOpacityScale: CGFloat = card.phase == "ambient" ? 1 : 0.30 + for particle in field { // One shared rise rate keeps the collision-safe layout rigid in Y, // while independent sine waves make the marks sway organically. let progress = (particle.y * 1.28 + t * 0.014).truncatingRemainder(dividingBy: 1.28) @@ -151,10 +156,10 @@ private final class WallpaperView: NSView { drawLogo( in: NSRect(x: x - logoWidth / 2, y: y - particle.size / 2, width: logoWidth, height: particle.size), - opacity: particle.opacity * breath + opacity: particle.opacity * breath * fieldOpacityScale ) - } - } else { + } + if card.phase != "ambient" { drawCard(in: rect, elapsed: ProcessInfo.processInfo.systemUptime - cardChangedAt) } } @@ -173,19 +178,10 @@ private final class WallpaperView: NSView { let raw = CGFloat(min(1, max(0, (elapsed - delay) / duration))) return raw * raw * (3 - 2 * raw) } - let logoProgress = ease(0.04, 0.42) - let titleProgress = ease(0.20, 0.52) + let titleProgress = ease(0.12, 0.52) NSGraphicsContext.saveGraphicsState() let portrait = canvas.width < canvas.height - let logoSize: CGFloat = portrait ? 116 : 132 - let logoRect = NSRect( - x: canvas.midX - logoSize / 2, - y: canvas.midY + (portrait ? 38 : 28) - (1 - logoProgress) * 24, - width: logoSize, - height: logoSize - ) - drawLogo(in: logoRect, opacity: logoProgress) let titleSize: CGFloat = portrait ? 48 : 62 NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current?.cgContext.setAlpha(titleProgress) @@ -193,7 +189,7 @@ private final class WallpaperView: NSView { card.title ?? "", in: NSRect( x: canvas.minX + canvas.width * 0.10, - y: canvas.midY - (portrait ? 190 : 170) - (1 - titleProgress) * 18, + y: canvas.midY - (portrait ? 125 : 110) - (1 - titleProgress) * 18, width: canvas.width * 0.80, height: portrait ? 250 : 220 ), @@ -251,10 +247,545 @@ private final class WallpaperView: NSView { } } +private struct MetaballInstance { + var placement: SIMD4 + var motion: SIMD4 + var appearance: SIMD4 +} + +private struct MetaballUniforms { + var viewport: SIMD2 + var time: Float + var dark: Float +} + +// One transparent, instanced Metal pass renders every logo. Only each logo's +// bounding quad is shaded, and the drawable is intentionally sub-Retina; this +// keeps real-time implicit surfaces practical for a quiet desktop backdrop. +private final class MetaballRenderer { + let layer = CAMetalLayer() + private let device: MTLDevice + private let queue: MTLCommandQueue + private let pipeline: MTLRenderPipelineState + private var instances: [MetaballInstance] = [] + private var timer: Timer? + private var started = ProcessInfo.processInfo.systemUptime + private var dark = false + + init?() { + guard let device = MTLCreateSystemDefaultDevice(), + let queue = device.makeCommandQueue() else { return nil } + self.device = device + self.queue = queue + layer.device = device + layer.pixelFormat = .bgra8Unorm + layer.framebufferOnly = true + layer.isOpaque = false + layer.maximumDrawableCount = 2 + layer.contentsGravity = .resize + + do { + let library = try device.makeLibrary(source: Self.shader, options: nil) + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "metaballVertex") + descriptor.fragmentFunction = library.makeFunction(name: "metaballFragment") + descriptor.colorAttachments[0].pixelFormat = layer.pixelFormat + descriptor.colorAttachments[0].isBlendingEnabled = true + descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one + descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + pipeline = try device.makeRenderPipelineState(descriptor: descriptor) + } catch { + fputs("Captutor metaball shader unavailable: \(error)\n", stderr) + return nil + } + + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + self?.draw() + } + RunLoop.main.add(timer!, forMode: .common) + } + + deinit { timer?.invalidate() } + + func setAppearance(dark: Bool) { self.dark = dark } + + func setOpacity(_ opacity: Float) { + CATransaction.begin() + CATransaction.setAnimationDuration(0.48) + layer.opacity = opacity + CATransaction.commit() + } + + func setLayout(bounds: CGRect, backingScale: CGFloat, + specs: [(CGFloat, CGFloat, CGFloat, TimeInterval, TimeInterval, CGFloat, Int)]) { + layer.frame = bounds + // Match the display's backing pixels for crisp foreground silhouettes. + // Small marks spend fewer shader steps and use softer shading below. + let renderScale = max(1, backingScale) + layer.contentsScale = renderScale + layer.drawableSize = CGSize(width: max(1, bounds.width * renderScale), + height: max(1, bounds.height * renderScale)) + let logoScale = min(max(bounds.width / 1280, 0.78), 1.30) * renderScale + instances = specs.enumerated().map { index, spec in + let opacity: Float = spec.2 > 100 ? 0.72 : 0.58 + return MetaballInstance( + placement: SIMD4(Float(spec.0), Float(spec.1), Float(spec.2 * logoScale), opacity), + motion: SIMD4(Float(spec.3), Float(spec.4), Float(spec.5 * renderScale), + spec.2 < 70 ? 1 : (spec.2 < 100 ? 0.52 : 0)), + appearance: SIMD4(Float(spec.6), index.isMultiple(of: 3) ? -1 : 1, + Float(index) * 0.713, 0) + ) + } + } + + private func draw() { + guard !instances.isEmpty, layer.opacity > 0.001, + let drawable = layer.nextDrawable(), + let command = queue.makeCommandBuffer(), + let encoder = command.makeRenderCommandEncoder(descriptor: renderPass(for: drawable.texture)) else { return } + var uniforms = MetaballUniforms( + viewport: SIMD2(Float(layer.drawableSize.width), Float(layer.drawableSize.height)), + time: Float(ProcessInfo.processInfo.systemUptime - started), + dark: dark ? 1 : 0 + ) + encoder.setRenderPipelineState(pipeline) + instances.withUnsafeBytes { bytes in + encoder.setVertexBytes(bytes.baseAddress!, length: bytes.count, index: 0) + } + encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 1) + encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0) + encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4, + instanceCount: instances.count) + encoder.endEncoding() + command.present(drawable) + command.commit() + } + + private func renderPass(for texture: MTLTexture) -> MTLRenderPassDescriptor { + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = texture + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].storeAction = .store + pass.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0) + return pass + } + + private static let shader = #""" + #include + using namespace metal; + + struct Instance { float4 placement; float4 motion; float4 appearance; }; + struct Uniforms { float2 viewport; float time; float dark; }; + struct Raster { + float4 position [[position]]; + float2 local; + float4 placement; + float4 motion; + float4 appearance; + }; + + constant float PI = 3.14159265359; + constant float TAU = 6.28318530718; + constant float3 centers[12] = { + float3( 0.40654, 1.21499, 0.00000), float3( 1.21550, 1.21499, 0.05450), + float3(-0.40235, 1.21499, -0.01517), float3(-0.40235, 0.40476, -0.05028), + float3(-1.21530, 0.40867, 0.02924), float3(-1.21139, -0.40476, 0.04214), + float3(-1.21139, -1.21499, -0.04094), float3(-0.40235, -1.21499, -0.03075), + float3( 0.40654, -1.21499, 0.04950), float3( 0.40654, -0.40476, 0.01693), + float3( 1.21550, -0.40476, -0.05421), float3( 1.21550, 0.40476, 0.00153) + }; + + float smoothMin(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; + } + + float field(float3 p) { + float d = length(p - centers[0]) - 0.355; + for (uint i = 1; i < 12; ++i) + d = smoothMin(d, length(p - centers[i]) - 0.355, 0.30); + return d; + } + + float3 rotateX(float3 p, float a) { + float c = cos(a), s = sin(a); + return float3(p.x, p.y * c - p.z * s, p.y * s + p.z * c); + } + float3 rotateY(float3 p, float a) { + float c = cos(a), s = sin(a); + return float3(p.x * c + p.z * s, p.y, -p.x * s + p.z * c); + } + float3 rotateZ(float3 p, float a) { + float c = cos(a), s = sin(a); + return float3(p.x * c - p.y * s, p.x * s + p.y * c, p.z); + } + float3 inverseTurn(float3 p, float3 angle) { + p = rotateZ(p, -angle.z); + p = rotateY(p, -angle.y); + return rotateX(p, -angle.x); + } + + vertex Raster metaballVertex(uint vertexID [[vertex_id]], uint instanceID [[instance_id]], + constant Instance *instances [[buffer(0)]], + constant Uniforms &u [[buffer(1)]]) { + const float2 corners[4] = { float2(-1,-1), float2(1,-1), float2(-1,1), float2(1,1) }; + Instance item = instances[instanceID]; + float2 corner = corners[vertexID]; + float size = item.placement.z; + float cycle = fract(item.placement.y + u.time / item.motion.x); + // A cosine lift keeps the volume fully visible and reverses gently at + // the vertical safe bounds instead of cropping or fading at the edge. + float rise = 0.5 - 0.5 * cos(cycle * TAU); + // Reserve about 3% of the display height beyond the rotating quad. This + // clears the menu-bar/dock occlusion as well as the raw pixel boundary. + float margin = size * 0.54 + max(8.0, u.viewport.y * 0.03); + float2 center = float2(item.placement.x * u.viewport.x, + mix(margin, u.viewport.y - margin, rise)); + center.x += sin(u.time * 0.18 + item.placement.y * TAU) * item.motion.z; + center.x = clamp(center.x, margin, u.viewport.x - margin); + float2 ndc = float2(center.x / u.viewport.x * 2.0 - 1.0, + center.y / u.viewport.y * 2.0 - 1.0); + ndc += corner * float2(size / u.viewport.x, size / u.viewport.y); + Raster out; + out.position = float4(ndc, 0, 1); + out.local = corner * 1.73; + out.placement = item.placement; + out.motion = item.motion; + out.appearance = item.appearance; + return out; + } + + fragment float4 metaballFragment(Raster in [[stage_in]], constant Uniforms &u [[buffer(0)]]) { + float direction = in.appearance.y; + float seed = in.appearance.z; + float turn = direction * (u.time / in.motion.y * TAU) + in.placement.y * TAU; + // Non-commensurate angular rates prevent the mark from remaining upright + // or repeating a simple front/back flip. + float3 angle = float3(turn * 0.67 + seed, turn, turn * 0.39 - seed * 0.6); + float3 ro = inverseTurn(float3(in.local, 3.0), angle); + float3 rd = inverseTurn(float3(0, 0, -1), angle); + + // Skip empty corners analytically before entering the implicit field. + float b = dot(ro, rd); + float c = dot(ro, ro) - 1.72 * 1.72; + float discriminant = b * b - c; + if (discriminant < 0.0) discard_fragment(); + float traveled = max(0.0, -b - sqrt(discriminant)); + float3 p = ro + rd * traveled; + float blur = in.motion.w; + float hitEpsilon = mix(0.0045, 0.012, blur); + uint stepLimit = blur > 0.75 ? 26 : (blur > 0.1 ? 32 : 40); + bool hit = false; + for (uint step = 0; step < stepLimit; ++step) { + float d = field(p); + if (d < hitEpsilon) { hit = true; break; } + traveled += max(d * 0.74, hitEpsilon); + if (traveled > 6.0) break; + p = ro + rd * traveled; + } + if (!hit) discard_fragment(); + + float e = mix(0.006, 0.016, blur); + float3 normal = normalize(float3( + field(p + float3(e,0,0)) - field(p - float3(e,0,0)), + field(p + float3(0,e,0)) - field(p - float3(0,e,0)), + field(p + float3(0,0,e)) - field(p - float3(0,0,e)) + )); + float3 worldNormal = rotateX(normal, angle.x); + worldNormal = rotateY(worldNormal, angle.y); + worldNormal = rotateZ(worldNormal, angle.z); + float3 light = normalize(float3(-0.48, 0.72, 0.62)); + float3 fill = normalize(float3(0.68, -0.22, 0.70)); + float diffuse = max(dot(worldNormal, light), 0.0); + float fillLight = max(dot(worldNormal, fill), 0.0); + float specular = pow(max(dot(worldNormal, normalize(light + float3(0,0,1))), 0.0), + mix(52.0, 18.0, blur)); + float rim = pow(1.0 - abs(worldNormal.z), 2.25); + + int variant = int(in.appearance.x + 0.5); + float baseValue; + if (u.dark > 0.5) + baseValue = variant == 0 ? 0.82 : (variant == 1 ? 0.48 : 0.68); + else + baseValue = variant == 0 ? 0.035 : (variant == 1 ? 0.72 : 0.22); + float lightShape = 0.25 + diffuse * 0.70 + fillLight * 0.18; + lightShape = mix(lightShape, 0.54 + diffuse * 0.36, blur * 0.72); + float value = baseValue * lightShape; + value += rim * mix(u.dark > 0.5 ? 0.42 : 0.30, 0.18, blur) + + specular * mix(0.92, 0.46, blur); + float3 color = float3(clamp(value, 0.0, 1.0)); + return float4(color, in.placement.w); + } + """# +} + +// Dimensional Fuser backdrop. The production SVG's twelve nodes become one +// live smooth-union surface, tumbling through yaw, pitch, and roll in Metal. +private final class FuserDimensionalView: NSView { + private struct MarkSpec { + let x: CGFloat + let phase: CGFloat + let size: CGFloat + let riseSeconds: TimeInterval + let turnSeconds: TimeInterval + let sway: CGFloat + let variant: Int + } + + private static let specs = [ + // A fixed staggered 4×4 field. Small phase offsets soften the rows while + // the shared 48-second rise keeps every protected gap invariant. + MarkSpec(x: 0.07, phase: 0.04, size: 138, riseSeconds: 48, turnSeconds: 25, sway: 12, variant: 0), + MarkSpec(x: 0.34, phase: 0.08, size: 48, riseSeconds: 48, turnSeconds: 18, sway: 9, variant: 1), + MarkSpec(x: 0.61, phase: 0.02, size: 92, riseSeconds: 48, turnSeconds: 22, sway: 11, variant: 2), + MarkSpec(x: 0.88, phase: 0.06, size: 60, riseSeconds: 48, turnSeconds: 20, sway: 9, variant: 0), + + MarkSpec(x: 0.18, phase: 0.29, size: 52, riseSeconds: 48, turnSeconds: 17, sway: 9, variant: 1), + MarkSpec(x: 0.45, phase: 0.33, size: 116, riseSeconds: 48, turnSeconds: 28, sway: 12, variant: 2), + MarkSpec(x: 0.72, phase: 0.27, size: 44, riseSeconds: 48, turnSeconds: 16, sway: 8, variant: 0), + MarkSpec(x: 0.95, phase: 0.31, size: 82, riseSeconds: 48, turnSeconds: 23, sway: 10, variant: 1), + + MarkSpec(x: 0.06, phase: 0.54, size: 76, riseSeconds: 48, turnSeconds: 21, sway: 10, variant: 2), + MarkSpec(x: 0.31, phase: 0.58, size: 46, riseSeconds: 48, turnSeconds: 15, sway: 8, variant: 0), + MarkSpec(x: 0.58, phase: 0.52, size: 146, riseSeconds: 48, turnSeconds: 31, sway: 13, variant: 1), + MarkSpec(x: 0.84, phase: 0.56, size: 58, riseSeconds: 48, turnSeconds: 19, sway: 9, variant: 2), + + MarkSpec(x: 0.16, phase: 0.79, size: 48, riseSeconds: 48, turnSeconds: 16, sway: 8, variant: 0), + MarkSpec(x: 0.42, phase: 0.83, size: 104, riseSeconds: 48, turnSeconds: 27, sway: 11, variant: 1), + MarkSpec(x: 0.68, phase: 0.77, size: 64, riseSeconds: 48, turnSeconds: 20, sway: 9, variant: 2), + MarkSpec(x: 0.93, phase: 0.81, size: 126, riseSeconds: 48, turnSeconds: 29, sway: 12, variant: 0), + ] + + private let background = CAGradientLayer() + private let glowA = CAGradientLayer() + private let glowB = CAGradientLayer() + private let ambient = CALayer() + private let metaballRenderer = MetaballRenderer() + private let cardLayer = CALayer() + private let cardMark = CALayer() + private let cardTitle = CATextLayer() + private let metaballSheets: [CGImage] + private let cardURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/share/captutor/wallpaper-card.json") + private var card = CardState.ambient + private var cardPayload = Data() + private var timer: Timer? + private var lastSize = CGSize.zero + + override init(frame frameRect: NSRect) { + metaballSheets = (0..<3).map(Self.loadMetaballSheet(variant:)) + super.init(frame: frameRect) + wantsLayer = true + let root = CALayer() + root.masksToBounds = true + layer = root + + background.startPoint = CGPoint(x: 0.04, y: 0.94) + background.endPoint = CGPoint(x: 0.96, y: 0.06) + root.addSublayer(background) + + for glow in [glowA, glowB] { + glow.type = .radial + glow.startPoint = CGPoint(x: 0.5, y: 0.5) + glow.endPoint = CGPoint(x: 0.96, y: 0.96) + glow.locations = [0, 0.48, 1] + root.addSublayer(glow) + } + root.addSublayer(ambient) + if let metaballRenderer { + root.addSublayer(metaballRenderer.layer) + } + + cardLayer.opacity = 0 + cardLayer.addSublayer(cardMark) + cardTitle.alignmentMode = .center + cardTitle.isWrapped = true + cardTitle.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + cardTitle.font = NSFont.systemFont(ofSize: 60, weight: .black) + cardLayer.addSublayer(cardTitle) + root.addSublayer(cardLayer) + + applyAppearance() + timer = Timer.scheduledTimer(withTimeInterval: 0.20, repeats: true) { [weak self] _ in + self?.reloadCard() + } + } + + required init?(coder: NSCoder) { nil } + deinit { timer?.invalidate() } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + applyAppearance() + } + + override func layout() { + super.layout() + background.frame = bounds + let glowSize = max(bounds.width, bounds.height) * 0.82 + glowA.frame = CGRect(x: -glowSize * 0.38, y: bounds.height - glowSize * 0.56, + width: glowSize, height: glowSize) + glowB.frame = CGRect(x: bounds.width - glowSize * 0.62, y: -glowSize * 0.42, + width: glowSize, height: glowSize) + metaballRenderer?.setLayout(bounds: bounds, + backingScale: window?.backingScaleFactor + ?? NSScreen.main?.backingScaleFactor ?? 2, + specs: Self.specs.map { + ($0.x, $0.phase, $0.size, $0.riseSeconds, $0.turnSeconds, $0.sway, $0.variant) + }) + cardLayer.frame = bounds + let portrait = bounds.width < bounds.height + let markSize: CGFloat = portrait ? 150 : 170 + cardMark.frame = CGRect(x: bounds.midX - markSize / 2, + y: bounds.midY + (portrait ? 55 : 25), + width: markSize, height: markSize) + cardTitle.frame = CGRect(x: bounds.width * 0.09, + y: bounds.midY - (portrait ? 230 : 195), + width: bounds.width * 0.82, + height: portrait ? 235 : 210) + guard bounds.size != lastSize else { return } + lastSize = bounds.size + rebuildAmbient() + } + + private func rebuildAmbient() { + ambient.sublayers?.forEach { $0.removeFromSuperlayer() } + ambient.frame = bounds + guard metaballRenderer == nil else { return } + let now = CACurrentMediaTime() + + for spec in Self.specs { + let scale = min(max(bounds.width / 1280, 0.78), 1.30) + let size = spec.size * scale + let travel = CALayer() + travel.bounds = CGRect(x: 0, y: 0, width: size, height: size) + travel.opacity = spec.size > 100 ? 0.72 : 0.58 + travel.allowsEdgeAntialiasing = true + + let solid = CALayer() + solid.frame = travel.bounds + solid.anchorPoint = CGPoint(x: 0.5, y: 0.5) + solid.position = CGPoint(x: size / 2, y: size / 2) + travel.addSublayer(solid) + + let turnStart = now - spec.turnSeconds * Double(spec.phase) + let sheet = metaballSheets[spec.variant] + let sprite = CALayer() + sprite.frame = solid.bounds + sprite.contents = sheet + sprite.contentsGravity = .resizeAspectFill + sprite.contentsScale = 2 + solid.addSublayer(sprite) + let turn = CABasicAnimation(keyPath: "transform.rotation.z") + turn.fromValue = spec.variant == 1 ? CGFloat.pi * 2 : 0 + turn.toValue = spec.variant == 1 ? 0 : CGFloat.pi * 2 + turn.duration = spec.turnSeconds + turn.repeatCount = .infinity + turn.timingFunction = CAMediaTimingFunction(name: .linear) + turn.beginTime = turnStart + turn.isRemovedOnCompletion = false + solid.add(turn, forKey: "metaballTurn") + + let x = bounds.width * spec.x + // All marks traverse exactly the same vertical span and duration. + // Their relative layout therefore survives every wrap unchanged. + let travelMargin = 190 * scale + let lowY = -travelMargin + let highY = bounds.height + travelMargin + let path = CGMutablePath() + path.move(to: CGPoint(x: x, y: lowY)) + path.addCurve(to: CGPoint(x: x - spec.sway * 0.45, y: bounds.height * 0.44), + control1: CGPoint(x: x + spec.sway, y: bounds.height * 0.13), + control2: CGPoint(x: x - spec.sway, y: bounds.height * 0.31)) + path.addCurve(to: CGPoint(x: x, y: highY), + control1: CGPoint(x: x + spec.sway, y: bounds.height * 0.68), + control2: CGPoint(x: x - spec.sway * 0.55, y: bounds.height * 0.88)) + travel.position = CGPoint(x: x, y: lowY + (highY - lowY) * spec.phase) + let rise = CAKeyframeAnimation(keyPath: "position") + rise.path = path + rise.calculationMode = .paced + rise.duration = spec.riseSeconds + rise.repeatCount = .infinity + rise.beginTime = now - spec.riseSeconds * Double(spec.phase) + rise.isRemovedOnCompletion = false + travel.add(rise, forKey: "fuserRise") + + ambient.addSublayer(travel) + } + } + + private func applyAppearance() { + let dark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + metaballRenderer?.setAppearance(dark: dark) + CATransaction.begin() + CATransaction.setAnimationDuration(0.55) + background.colors = (dark ? [ + NSColor(hex: 0x050505), NSColor(hex: 0x141414), NSColor(hex: 0x080808), + ] : [ + NSColor(hex: 0xFFFFFF), NSColor(hex: 0xECECEC), NSColor(hex: 0xFAFAFA), + ]).map(\.cgColor) + let smoke = NSColor(hex: dark ? 0xFFFFFF : 0x111111) + let silver = NSColor(hex: dark ? 0xA8A8A8 : 0x8E8E8E) + glowA.colors = [smoke.withAlphaComponent(dark ? 0.12 : 0.09).cgColor, + smoke.withAlphaComponent(dark ? 0.045 : 0.032).cgColor, + smoke.withAlphaComponent(0).cgColor] + glowB.colors = [silver.withAlphaComponent(dark ? 0.10 : 0.08).cgColor, + silver.withAlphaComponent(dark ? 0.038 : 0.028).cgColor, + silver.withAlphaComponent(0).cgColor] + cardTitle.foregroundColor = (dark ? NSColor.white : NSColor(hex: 0x171717)).cgColor + cardMark.contents = metaballSheets[0] + cardMark.contentsGravity = .resizeAspect + cardMark.shadowColor = (dark ? NSColor.white : NSColor.black).cgColor + cardMark.shadowRadius = 28 + cardMark.shadowOpacity = 0.46 + CATransaction.commit() + } + + private func reloadCard() { + guard let data = try? Data(contentsOf: cardURL), data != cardPayload, + let decoded = try? JSONDecoder().decode(CardState.self, from: data) else { return } + cardPayload = data + card = decoded + cardTitle.string = decoded.title ?? "" + CATransaction.begin() + CATransaction.setAnimationDuration(0.48) + ambient.opacity = decoded.phase == "ambient" ? 1 : 0.10 + metaballRenderer?.setOpacity(decoded.phase == "ambient" ? 1 : 0.10) + cardLayer.opacity = decoded.phase == "ambient" ? 0 : 1 + CATransaction.commit() + } + + private static func loadMetaballSheet(variant: Int) -> CGImage { + guard let url = Bundle.main.url(forResource: "fuser-metaballs-\(variant)", withExtension: "png"), + let image = NSImage(contentsOf: url), + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + fatalError("Captutor Wallpaper is missing fuser-metaballs-\(variant).png") + } + return cgImage + } +} + +private extension NSColor { + convenience init(hex: UInt32) { + self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1) + } +} + private final class AppDelegate: NSObject, NSApplicationDelegate { private var windows: [NSWindow] = [] func applicationDidFinishLaunching(_ notification: Notification) { + let args = CommandLine.arguments + let brandIndex = args.firstIndex(of: "--brand") + let brand = brandIndex.flatMap { index in + args.indices.contains(index + 1) ? args[index + 1].lowercased() : nil + } ?? "fuser" + let prototype = args.contains("--prototype") for screen in NSScreen.screens { let window = NSWindow( contentRect: screen.frame, @@ -263,15 +794,24 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { defer: false, screen: screen ) - window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1) + window.level = NSWindow.Level(rawValue: prototype + ? Int(CGWindowLevelForKey(.desktopIconWindow)) - 1 + : Int(CGWindowLevelForKey(.desktopWindow)) + 1) window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] window.ignoresMouseEvents = true window.isOpaque = true window.hasShadow = false - window.contentView = WallpaperView(frame: NSRect(origin: .zero, size: screen.frame.size)) + if brand == "classic" { + window.contentView = WallpaperView(frame: NSRect(origin: .zero, size: screen.frame.size)) + } else { + window.contentView = FuserDimensionalView(frame: NSRect(origin: .zero, size: screen.frame.size)) + } window.orderFrontRegardless() windows.append(window) } + print("Captutor Wallpaper brand=\(brand) prototype=\(prototype) " + + "renderer=\(brand == "classic" ? "vector-field" : "realtime-metal-metaballs")") + fflush(stdout) } } diff --git a/captutor/bin/cdp-frame.mjs b/captutor/bin/cdp-frame.mjs new file mode 100755 index 000000000..9c6581332 --- /dev/null +++ b/captutor/bin/cdp-frame.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// A bounded, invisible browser preflight for Captutor pathfinding. +// +// One CDP connection returns the current URL, visible controls, stable locator +// candidates, focus, viewport, and React Flow nodes/handles/edges. Optionally +// save a compositor screenshot without putting any Frame/Puppet UI on screen. + +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { withSession } from "../lib/cdp.mjs"; + +const args = process.argv.slice(2); +const value = (flag, fallback = null) => { + const index = args.indexOf(flag); + return index >= 0 ? args[index + 1] : fallback; +}; +const match = value("--match", "fuser.studio"); +const screenshotPath = value("--screenshot"); +const compact = args.includes("--compact"); + +if (args.includes("--help") || args.includes("-h")) { + console.log("usage: node bin/cdp-frame.mjs [--match text] [--screenshot path.png] [--compact]"); + process.exit(0); +} + +const timeoutMs = Number(value("--timeout", "15000")); +const watchdog = setTimeout(() => { + console.error(`CDP frame timed out after ${timeoutMs}ms`); + process.exit(124); +}, timeoutMs); +try { + const result = await withSession(match, async (cdp) => { + const frame = await cdp.frame(); + if (screenshotPath) { + const out = resolve(screenshotPath); + writeFileSync(out, await cdp.screenshot()); + frame.screenshot = out; + } + return frame; + }); + console.log(JSON.stringify(result, null, compact ? 0 : 2)); +} catch (error) { + console.error(JSON.stringify({ + ok:false, + code:error.code || "CDP_FRAME_FAILED", + message:error.message, + details:error.details || null, + })); + process.exitCode = 1; +} finally { + clearTimeout(watchdog); +} diff --git a/captutor/bin/install.sh b/captutor/bin/install.sh index 23859f608..de53eb053 100755 --- a/captutor/bin/install.sh +++ b/captutor/bin/install.sh @@ -13,7 +13,10 @@ rsync -a --delete \ mkdir -p "$HOME/.local/bin" "$HOME/Desktop/outbox" install -m 755 "$SOURCE/vendor/reel.mjs" "$HOME/.local/bin/reel.mjs" +/usr/bin/swiftc -O "$SOURCE/bin/captutor-pointer.swift" -o "$HOME/.local/bin/captutor-pointer" +/usr/bin/swiftc -O "$SOURCE/bin/captutor-cursor.swift" -o "$HOME/.local/bin/captutor-cursor" echo "✓ Captutor installed at $DEST" echo "✓ reel controller installed at $HOME/.local/bin/reel.mjs" +echo "✓ native tutorial cursor installed at $HOME/.local/bin/captutor-cursor" echo "✓ delivery outbox at $HOME/Desktop/outbox" diff --git a/captutor/bin/render-fuser-metaballs.swift b/captutor/bin/render-fuser-metaballs.swift new file mode 100644 index 000000000..92445dca4 --- /dev/null +++ b/captutor/bin/render-fuser-metaballs.swift @@ -0,0 +1,189 @@ +// Bake the canonical twelve-node Fuser mark as a smooth 3D metaball surface. +// Runtime Captutor rotates these stills; raymarching never runs while filming. + +import AppKit +import Foundation +import simd + +guard CommandLine.arguments.count == 2 else { + fputs("usage: render-fuser-metaballs \n", stderr) + exit(2) +} + +private let output = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) +// One generous source image lets Core Animation provide continuous rotation +// without atlas stepping or the memory cost of hundreds of decoded views. +private let frameSize = 384 +private let columns = 1 +private let rows = 1 +private let frameCount = columns * rows +private let sheetWidth = columns * frameSize +private let sheetHeight = rows * frameSize + +private struct Palette { + let base: SIMD3 + let accent: SIMD3 + let edge: SIMD3 +} + +private let palettes = [ + // Obsidian, pearl, and graphite remain strictly neutral. Their different + // values make overlapping marks legible without leaving Fuser's B/W system. + Palette(base: SIMD3(repeating: 0.035), accent: SIMD3(repeating: 1.00), edge: SIMD3(repeating: 0.005)), + Palette(base: SIMD3(repeating: 0.76), accent: SIMD3(repeating: 1.00), edge: SIMD3(repeating: 0.16)), + Palette(base: SIMD3(repeating: 0.22), accent: SIMD3(repeating: 0.94), edge: SIMD3(repeating: 0.018)), +] + +// Centers are taken directly from captutor/assets/fuser-mark.svg. Normalizing +// around its 24×24 viewBox preserves the production mark's proportions. +private let svgCenters: [(Float, Float)] = [ + (15.1507, 2.5838), (21.4201, 2.5838), (8.8818, 2.5838), + (8.8818, 8.86312), (2.58145, 8.83282), (2.61171, 15.1369), + (2.61171, 21.4162), (8.8818, 21.4162), (15.1507, 21.4162), + (15.1507, 15.1369), (21.4201, 15.1369), (21.4201, 8.86312), +] +private let sourceCenters: [SIMD3] = svgCenters.enumerated().map { index, point in + let x = (point.0 - 12) / 7.75 + let y = (12 - point.1) / 7.75 + // A tiny alternating depth keeps the front silhouette faithful while the + // side view reveals that this is a living cluster, not a flat extrusion. + let z = sin(Float(index) * 1.71) * 0.055 + return SIMD3(x, y, z) +} + +@Sendable @inline(__always) private func rotate(_ point: SIMD3, yaw: Float, pitch: Float) -> SIMD3 { + let cy = cos(yaw), sy = sin(yaw) + let cp = cos(pitch), sp = sin(pitch) + let yTurned = SIMD3(point.x * cy + point.z * sy, point.y, -point.x * sy + point.z * cy) + return SIMD3(yTurned.x, yTurned.y * cp - yTurned.z * sp, + yTurned.y * sp + yTurned.z * cp) +} + +@Sendable @inline(__always) private func smoothMin(_ a: Float, _ b: Float, _ blend: Float) -> Float { + let h = max(blend - abs(a - b), 0) / blend + return min(a, b) - h * h * blend * 0.25 +} + +@Sendable @inline(__always) private func fieldDistance(_ point: SIMD3, centers: [SIMD3]) -> Float { + let radius: Float = 0.355 + var distance = simd_length(point - centers[0]) - radius + for center in centers.dropFirst() { + distance = smoothMin(distance, simd_length(point - center) - radius, 0.30) + } + return distance +} + +@Sendable @inline(__always) private func surfaceNormal(_ point: SIMD3, centers: [SIMD3]) -> SIMD3 { + let epsilon: Float = 0.005 + let x = fieldDistance(point + SIMD3(epsilon, 0, 0), centers: centers) + - fieldDistance(point - SIMD3(epsilon, 0, 0), centers: centers) + let y = fieldDistance(point + SIMD3(0, epsilon, 0), centers: centers) + - fieldDistance(point - SIMD3(0, epsilon, 0), centers: centers) + let z = fieldDistance(point + SIMD3(0, 0, epsilon), centers: centers) + - fieldDistance(point - SIMD3(0, 0, epsilon), centers: centers) + return simd_normalize(SIMD3(x, y, z)) +} + +@Sendable @inline(__always) private func clamp01(_ value: Float) -> Float { min(max(value, 0), 1) } +@Sendable @inline(__always) private func mix(_ a: SIMD3, _ b: SIMD3, _ t: Float) -> SIMD3 { + a + (b - a) * clamp01(t) +} + +@Sendable private func shade(pixelX: Int, pixelY: Int, centers: [SIMD3], palette: Palette) -> SIMD4 { + let u = (Float(pixelX) + 0.5) / Float(frameSize) + let v = (Float(pixelY) + 0.5) / Float(frameSize) + let viewScale: Float = 3.45 + var point = SIMD3((u - 0.5) * viewScale, (0.5 - v) * viewScale, 3.0) + let ray = SIMD3(0, 0, -1) + var traveled: Float = 0 + var hit = false + + for _ in 0..<88 { + let distance = fieldDistance(point, centers: centers) + if distance < 0.0035 { + hit = true + break + } + let advance = max(distance * 0.72, 0.006) + traveled += advance + if traveled > 6.2 { break } + point += ray * advance + } + guard hit else { return SIMD4(0, 0, 0, 0) } + + let normal = surfaceNormal(point, centers: centers) + let light = simd_normalize(SIMD3(-0.48, 0.72, 0.62)) + let fill = simd_normalize(SIMD3(0.68, -0.22, 0.70)) + let view = SIMD3(0, 0, 1) + let diffuse = max(simd_dot(normal, light), 0) + let fillLight = max(simd_dot(normal, fill), 0) + let halfVector = simd_normalize(light + view) + let specular = pow(max(simd_dot(normal, halfVector), 0), 54) + let rim = pow(1 - max(simd_dot(normal, view), 0), 2.35) + let facing = clamp01(normal.z * 0.5 + 0.5) + var color = mix(palette.edge, palette.base, 0.28 + facing * 0.72) + color *= 0.24 + diffuse * 0.71 + fillLight * 0.18 + color += palette.accent * (rim * 0.42) + color += SIMD3(repeating: 1) * (specular * 0.88) + + // Contact darkening where neighboring balls merge helps the lobes remain + // legible without drawing seams into the implicit surface. + let nearest = centers.map { simd_length(point - $0) }.sorted() + if nearest.count > 1 { + let junction = clamp01((0.53 - nearest[1]) * 2.6) + color *= 1 - junction * 0.14 + } + + return SIMD4(UInt8(clamp01(color.x) * 255), + UInt8(clamp01(color.y) * 255), + UInt8(clamp01(color.z) * 255), 255) +} + +try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) +let rotatedCenters: [[SIMD3]] = (0...allocate(capacity: byteCount) + pixels.initialize(repeating: 0, count: byteCount) + + DispatchQueue.concurrentPerform(iterations: sheetHeight) { outputY in + let cellYFromTop = outputY / frameSize + let localY = outputY % frameSize + // Frame zero occupies the bottom sheet row, matching CALayer contentsRect. + let frameRow = rows - 1 - cellYFromTop + for outputX in 0..?] = [pixels] + guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: &planes, + pixelsWide: sheetWidth, pixelsHigh: sheetHeight, + bitsPerSample: 8, samplesPerPixel: 4, + hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, + bitmapFormat: .alphaNonpremultiplied, + bytesPerRow: sheetWidth * 4, bitsPerPixel: 32), + let png = bitmap.representation(using: .png, properties: [.compressionFactor: 0.86]) else { + pixels.deallocate() + fatalError("could not encode metaball sheet") + } + let destination = output.appendingPathComponent("fuser-metaballs-\(variant).png") + try png.write(to: destination) + pixels.deallocate() + print("✓ \(destination.lastPathComponent) · \(png.count / 1024) KB") +} diff --git a/captutor/bin/stage.mjs b/captutor/bin/stage.mjs index 939080804..ce9003861 100755 --- a/captutor/bin/stage.mjs +++ b/captutor/bin/stage.mjs @@ -6,16 +6,21 @@ import { existsSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { enterStageMode, exitStageMode } from "../lib/stage-mode.mjs"; +import { enterStageMode, exitStageMode, parseStageFlags } from "../lib/stage-mode.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const REEL = resolve(HERE, "../vendor/reel.mjs"); const REEL_STATE = join(process.env.HOME, ".local", "share", "slab", "state", "reel.state"); const rawArgs = process.argv.slice(2); -const vertical = rawArgs.includes("--vertical"); -const args = rawArgs.filter((arg) => arg !== "--vertical"); +let parsed; +try { parsed = parseStageFlags(rawArgs); } +catch (error) { + console.error(error.message); + process.exit(2); +} +const { vertical, brand, args } = parsed; if (!args.length) { - console.error("usage: node bin/stage.mjs [--vertical] render [captutor options]"); + console.error("usage: node bin/stage.mjs [--vertical] [--brand fuser|classic] render [captutor options]"); process.exit(2); } @@ -28,6 +33,34 @@ const forward = (signal) => { process.on("SIGINT", () => forward("SIGINT")); process.on("SIGTERM", () => forward("SIGTERM")); +function verifyFilmingPermissions() { + const recorder = spawnSync(process.execPath, [REEL, "status"], { + encoding: "utf8", + env: process.env, + }); + if (recorder.status !== 0) { + throw new Error( + `SlabMenubar recording bridge is unavailable: ${(recorder.stderr || recorder.stdout || "").trim()}`, + ); + } + let status; + try { status = JSON.parse(recorder.stdout || "{}"); } + catch { throw new Error("SlabMenubar recording bridge returned invalid status"); } + if (typeof status.recording !== "boolean") { + throw new Error("SlabMenubar recording bridge did not report recording permission state"); + } + + const accessibility = spawnSync("/usr/bin/osascript", [ + "-e", 'tell application "System Events" to get UI elements enabled', + ], { encoding:"utf8", env:process.env }); + if (accessibility.status !== 0 || accessibility.stdout.trim() !== "true") { + throw new Error( + "System Events Accessibility is not enabled for Captutor; allow it in System Settings before filming", + ); + } + console.log("✓ filming permissions — SlabMenubar recorder + System Events Accessibility"); +} + function stopOwnedReelIfNeeded() { if (!child || !existsSync(REEL_STATE)) return; let state; @@ -46,9 +79,10 @@ function stopOwnedReelIfNeeded() { let code = 1; try { + verifyFilmingPermissions(); // Enter is inside the guarded region deliberately: if a preference change // fails halfway through, the state file still lets `finally` unwind it. - await enterStageMode({ vertical }); + await enterStageMode({ vertical, brand }); if (vertical) { // Rotation can leave Chrome's process alive with no page window. Relaunch // the dedicated filming profile only when its Fuser target disappeared. @@ -68,9 +102,10 @@ try { ...process.env, CAPTUTOR_STAGE_MODE: "1", CAPTUTOR_VERTICAL_MODE: vertical ? "1" : "0", - // Accessibility-free filming seats can retain Captutor's deterministic - // in-page pointer while still using the full native Stage desktop. - CAPTUTOR_REAL_CURSOR: process.env.CAPTUTOR_REAL_CURSOR ?? "1", + CAPTUTOR_BRAND: brand, + // The capture-visible Swift overlay is the normal filmed pointer. Keep + // the system cursor opt-in for explicitly human-driven takes only. + CAPTUTOR_REAL_CURSOR: process.env.CAPTUTOR_REAL_CURSOR ?? "0", CDP_PORT: process.env.CDP_PORT || "9333", PATH: `/opt/homebrew/bin:${process.env.HOME}/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin`, }, diff --git a/captutor/captutor.mjs b/captutor/captutor.mjs index 2faa743da..fa380590a 100644 --- a/captutor/captutor.mjs +++ b/captutor/captutor.mjs @@ -37,8 +37,10 @@ import { dirname, join, resolve, basename } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { narrate } from "./lib/narrate.mjs"; -import { attach } from "./lib/cdp.mjs"; -import { clickOn, dragBetween, pointAt, typeInto, INSTALL } from "./lib/cursor.mjs"; +import { attach, BrowserCrashError } from "./lib/cdp.mjs"; +import { + clickOn, dragBetween, pointAt, stopNativeCursor, typeInto, +} from "./lib/cursor.mjs"; import { spotlight, outline, burst, zoom, resetCamera, clearEffects, } from "./lib/effects.mjs"; @@ -50,6 +52,7 @@ import { ensureSignedIn, WORKSPACE } from "./lib/login.mjs"; import * as credits from "./lib/credits.mjs"; import { publishToOutbox } from "./lib/outbox.mjs"; import { presentSignboard, setAmbient } from "./lib/signboard.mjs"; +import { assertHiDPIStage } from "./lib/stage-contract.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -60,6 +63,12 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const INSTALLED_REEL = join(process.env.HOME, ".local", "bin", "reel.mjs"); const REEL = process.env.CAPTUTOR_REEL || (existsSync(INSTALLED_REEL) ? INSTALLED_REEL : join(resolve(HERE, "../../.."), "slab", "bin", "reel.mjs")); +const INSTALLED_FRAME = join(process.env.HOME, ".local", "bin", "frame.mjs"); +const REPO_FRAME = join(resolve(HERE, ".."), "slab", "bin", "frame.mjs"); +const FRAME = process.env.CAPTUTOR_FRAME + || (existsSync(INSTALLED_FRAME) ? INSTALLED_FRAME + : existsSync(REPO_FRAME) ? REPO_FRAME + : join(resolve(HERE, "../../.."), "slab", "bin", "frame.mjs")); const FUSER = process.env.FUSER_REPO || `${process.env.HOME}/Developer/fuser`; const DOCS_PUBLIC = join(FUSER, "apps", "docs", "public"); @@ -67,6 +76,23 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const now = () => Date.now() / 1000; const REAL_CURSOR = process.env.CAPTUTOR_REAL_CURSOR === "1"; const STAGE_MODE = process.env.CAPTUTOR_STAGE_MODE === "1"; +// CAPTUTOR_TASK_GID is present on every Iris mission, including a worker that +// was already running when this invariant was deployed. Local development +// renders remain possible without Stage; fleet takes do not. +const REQUIRE_HIDPI = process.env.CAPTUTOR_REQUIRE_HIDPI === "1" + || Boolean(process.env.CAPTUTOR_TASK_GID); +const VERTICAL_MODE = process.env.CAPTUTOR_VERTICAL_MODE === "1"; + +// Frame's native OCR/target surfaces live above app windows, so even a window +// capture can film one. Retire every Frame-owned transient immediately before +// the reel starts. If Frame is installed, failure is a capture-safety failure: +// it is better to abort a take than ship tooling UI inside the tutorial. +function clearFrameOverlays() { + if (!existsSync(FRAME)) return; + execFileSync(process.execPath, [ + FRAME, "local", "--clear-overlays", "--quiet-overlay", "--no-ocr", "--json", + ], { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] }); +} // Some filming seats (notably clamshell Macs on native-only external panels) // cannot expose Captutor's usual 2560×1440/1280×720 HiDPI pair. Keep those @@ -153,6 +179,27 @@ function logFailure({ sp, locale, format, attempt, error }) { return record; } +function logBrowserFailure({ sp, locale, format, attempt, error, beat, elapsed, aborted }) { + mkdirSync(dirname(FAILURE_LOG), { recursive: true }); + const record = { + schema:"captutor-failure/v1", + at:new Date().toISOString(), + screenplay:sp.slug, + locale, + format, + attempt, + reason:"browser-renderer-crash", + message:error.message, + beat:Math.max(0, beat) + 1, + elapsedSec:Number(elapsed.toFixed(3)), + abortedVideo:aborted, + signal:error.details?.signal || null, + action:"abort-and-restart-browser", + }; + appendFileSync(FAILURE_LOG, `${JSON.stringify(record)}\n`); + return record; +} + // `since` is the load-bearing value: the wall-clock instant the recorder's first // frame exists, on the same machine and the same epoch as our own Date.now(). // Every beat offset is measured against it, so audio and video share an origin. @@ -247,6 +294,11 @@ async function sizeWindow(cdp, win) { } async function cmdRender(sp, workDir, locale, format, attempt = 1) { + assertHiDPIStage({ + required:REQUIRE_HIDPI, + stageMode:STAGE_MODE, + vertical:VERTICAL_MODE, + }); const beats = await cmdNarrate(sp, workDir, locale); const t = translator(locale); const s = selectors(t); @@ -279,14 +331,23 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { ["Overlay.setShowAdHighlights", { show: false }], ].map(([method, params]) => cdp.send(method, params).catch(() => {}))); - if (REAL_CURSOR) { - await cdp.eval(`(() => { - document.getElementById('__captutor_cursor')?.remove(); - delete window.__captutor; - })()`); - } else { - await cdp.eval(INSTALL); - } + // Puppet and the shared analysis layer draw directly into the page at the + // highest z-index. They normally self-fade, but a tutorial take must not + // depend on a timeout or on which automation client touched the tab last. + await cdp.eval(`(() => { + document.getElementById('__puppet_cursor')?.remove(); + document.getElementById('__analysis_overlay')?.remove(); + clearTimeout(window.__pcTimer); + delete window.__pcTimer; + })()`); + + // Captutor's visible pointer is a native click-through Swift surface. Remove + // any cursor left in the page by an older build; trusted input still travels + // through CDP and therefore remains independent from the presentation layer. + await cdp.eval(`(() => { + document.getElementById('__captutor_cursor')?.remove(); + delete window.__captutor; + })()`); // The screenplay says `click('[data-testid=fuse]')`, not // `click(cdp, '[data-testid=fuse]')` — the session is plumbing, and a @@ -437,6 +498,30 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { await cdp.send("Page.bringToFront"); await sleep(600); + clearFrameOverlays(); + + // /json retains the original Fuser URL and title after a renderer dies, so + // those fields are not a health check. Require the page itself to answer just + // before the camera starts; this catches a pre-existing "Aw, Snap!" without + // filming it or debiting a generation. + try { + await cdp.assertHealthy("pre-record"); + const screen = await cdp.eval(`({ + width: screen.width, + height: screen.height, + dpr: window.devicePixelRatio, + })`); + assertHiDPIStage({ + required:REQUIRE_HIDPI, + stageMode:STAGE_MODE, + vertical:VERTICAL_MODE, + screen, + }); + } catch (error) { + await cdp.close(); + throw error; + } + const stageDisplay = STAGE_MODE && F.compose?.fullDesktop; console.log(`\n● recording (${stageDisplay ? "full Stage desktop" : `window: ${sp.window || "whole display"}`})`); const state = reelStart({ @@ -456,6 +541,7 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { const stopRecording = (out) => { if (!recording) return out; recording = false; + stopNativeCursor(); return reelStop(out); }; @@ -463,7 +549,7 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { await sleep((sp.leadInMs ?? 700)); // a beat of stillness before we start moving if (sp.openingCard) { const card = { - phase: "title", ...localizeCard(sp.openingCard), + phase: "title", ...localizeCard(sp.openingCard), title: "Learn Fuser", }; await perform("signboard", { card, role:"opening" }, () => presentSignboard(cdp, card, { @@ -521,9 +607,17 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { return null; })(); + const browserGuard = (async () => { + while (recording) { + await cdp.assertHealthy(`recording beat ${Math.max(0, activeBeat) + 1}`); + await sleep(500); + } + return null; + })(); + let timed; try { - timed = await Promise.race([take, upgradeGuard]); + timed = await Promise.race([take, upgradeGuard, browserGuard]); } catch (err) { if (err instanceof UpgradeInterruption) { // The camera is already stopped. Let any in-flight screenplay promise @@ -544,6 +638,25 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { return cmdRender(sp, workDir, locale, format, attempt + 1); } + if (err instanceof BrowserCrashError) { + const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-"); + const aborted = stopRecording(join(workDir, `aborted-browser-${stamp}.mp4`)); + logBrowserFailure({ + sp, locale, format, attempt, error:err, + beat:activeBeat, elapsed:now() - since, aborted, + }); + console.error(`\n✗ browser renderer crashed at beat ${Math.max(0, activeBeat) + 1}; take aborted`); + if (purse) { + await credits.settle(cdp, purse, { + slug:sp.slug, locale, format, aborted:true, reason:"browser-renderer-crash", + }).catch((settleError) => { + console.warn(` credit settlement unavailable after crash: ${settleError.message}`); + }); + } + await cdp.close(); + throw err; + } + console.error(`\n✗ beat ${activeBeat + 1} failed: ${err.message}`); stopRecording(join(workDir, "aborted.mp4")); if (purse) await credits.settle(cdp, purse, { slug: sp.slug, locale, format, aborted: true }); diff --git a/captutor/lib/cdp.mjs b/captutor/lib/cdp.mjs index 9a53c6975..3cf09969e 100644 --- a/captutor/lib/cdp.mjs +++ b/captutor/lib/cdp.mjs @@ -13,8 +13,22 @@ // (WebSocket is a Node >= 22 global — no import, no `ws` dependency.) +import { basename } from "node:path"; + const HOST = process.env.CDP_HOST || "127.0.0.1"; const PORT = process.env.CDP_PORT || "9222"; +const COMMAND_TIMEOUT_MS = Number(process.env.CAPTUTOR_CDP_COMMAND_TIMEOUT_MS || 15_000); +const HEALTH_TIMEOUT_MS = Number(process.env.CAPTUTOR_CDP_HEALTH_TIMEOUT_MS || 4_000); +const CRASH_EVENTS = new Set(["Inspector.targetCrashed", "Target.targetCrashed"]); + +export class BrowserCrashError extends Error { + constructor(message, details = {}) { + super(message); + this.name = "BrowserCrashError"; + this.code = "BROWSER_RENDERER_CRASH"; + this.details = details; + } +} export async function attach(urlMatch) { const list = await (await fetch(`http://${HOST}:${PORT}/json`)).json(); @@ -27,38 +41,160 @@ export async function attach(urlMatch) { `no CDP page${urlMatch ? ` matching "${urlMatch}"` : ""}. open pages:\n` + pages.map((p) => ` ${p.url}`).join("\n")); } - return new Session(target.webSocketDebuggerUrl); + const session = new Session(target.webSocketDebuggerUrl); + try { + await session.monitorCrashes(); + return session; + } catch (error) { + await session.close(); + throw error; + } +} + +// Short-lived inspection should always use this wrapper. A bare `attach()` in +// a one-off node script leaves the WebSocket holding the process open, which +// turns a two-second preflight into a tool timeout. Long-running screenplays +// still own their Session directly and close it in Captutor's render teardown. +export async function withSession(urlMatch, action) { + const session = await attach(urlMatch); + try { return await action(session); } + finally { await session.close(); } } -class Session { +export class Session { constructor(wsUrl) { this.wsUrl = wsUrl; this.id = 0; this.pending = new Map(); + this.crashEvent = null; + this.closing = false; + const entrypoint = process.argv[1] ? basename(process.argv[1]) : ""; + // Claude frequently explores with `node -e` or a throwaway *probe*. Those + // scripts used to print their answer and then live forever because nobody + // closed CDP. Give only those explicitly ephemeral entrypoints a brief idle + // reaper; production captutor.mjs sessions remain fully caller-owned. + this.ephemeralIdleMs = ( + process.env.CAPTUTOR_CDP_EPHEMERAL === "1" + || !entrypoint + || /(?:^|[-_.])probe/i.test(entrypoint) + ) ? 5_000 : 0; + this.idleTimer = null; this.ready = new Promise((res, rej) => { // node:ws is not built in; use the global WebSocket (node >= 22). this.ws = new globalThis.WebSocket(wsUrl); this.ws.addEventListener("open", () => res()); this.ws.addEventListener("error", (e) => rej(e)); + this.ws.addEventListener("close", () => { + if (!this.closing) this.markCrashed("WebSocket.closed", {}); + else this.rejectPending(new Error("CDP session closed")); + }); this.ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); + if (CRASH_EVENTS.has(msg.method)) { + this.markCrashed(msg.method, msg.params || {}); + return; + } const p = this.pending.get(msg.id); if (!p) return; this.pending.delete(msg.id); - msg.error ? p.rej(new Error(JSON.stringify(msg.error))) : p.res(msg.result); + clearTimeout(p.timer); + if (msg.error) { + const error = new Error(JSON.stringify(msg.error)); + if (/target.*crash|renderer.*crash/i.test(msg.error.message || "")) { + this.markCrashed("CDP.error", { method:p.method, error:msg.error }); + p.rej(this.crashError()); + } else { + p.rej(error); + } + } else { + p.res(msg.result); + } + this.armIdleClose(); }); }); } - async send(method, params = {}) { + rejectPending(error) { + for (const { rej: reject, timer } of this.pending.values()) { + clearTimeout(timer); + reject(error); + } + this.pending.clear(); + } + + markCrashed(method, params) { + if (!this.crashEvent) { + this.crashEvent = { method, params, at:new Date().toISOString() }; + } + this.rejectPending(this.crashError()); + } + + crashError(context = "") { + const where = context ? ` during ${context}` : ""; + const signal = this.crashEvent?.method || "unresponsive target"; + return new BrowserCrashError( + `browser renderer unavailable${where} (${signal})`, + { context, signal, event:this.crashEvent }, + ); + } + + armIdleClose() { + clearTimeout(this.idleTimer); + if (!this.ephemeralIdleMs || this.pending.size) return; + this.idleTimer = setTimeout(() => { void this.close(); }, this.ephemeralIdleMs); + } + + async send(method, params = {}, { timeoutMs = COMMAND_TIMEOUT_MS } = {}) { await this.ready; + if (this.crashEvent) throw this.crashError(method); + clearTimeout(this.idleTimer); const id = ++this.id; return new Promise((res, rej) => { - this.pending.set(id, { res, rej }); + const timer = timeoutMs > 0 ? setTimeout(() => { + this.pending.delete(id); + rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)); + this.armIdleClose(); + }, timeoutMs) : null; + this.pending.set(id, { res, rej, timer, method }); this.ws.send(JSON.stringify({ id, method, params })); }); } + // Inspector.targetCrashed is the authoritative live signal. The bounded + // Runtime heartbeat covers two less-obvious cases: attaching after the crash + // already happened, and Chrome keeping a dead target in /json with its old + // title and URL (which is exactly how an "Aw, Snap!" page fooled Captutor). + async monitorCrashes() { + try { + await this.send("Inspector.enable", {}, { timeoutMs:HEALTH_TIMEOUT_MS }); + await this.assertHealthy("attach"); + } catch (error) { + if (error instanceof BrowserCrashError) throw error; + this.markCrashed("CDP.unresponsive", { + phase:"attach", message:error.message, + }); + throw this.crashError("attach"); + } + } + + async assertHealthy(context = "browser", { timeoutMs = HEALTH_TIMEOUT_MS } = {}) { + if (this.crashEvent) throw this.crashError(context); + try { + const result = await this.send("Runtime.evaluate", { + expression:"({ readyState:document.readyState, href:location.href })", + returnByValue:true, + }, { timeoutMs }); + if (!result?.result || result.exceptionDetails) { + throw new Error("health expression did not return a page state"); + } + return result.result.value; + } catch (error) { + if (error instanceof BrowserCrashError) throw error; + this.markCrashed("CDP.unresponsive", { context, message:error.message }); + throw this.crashError(context); + } + } + /// Evaluate ONE expression. Wrap statements in an IIFE — top-level `const` /// returns undefined and silently swallows what you meant to return. async eval(expression) { @@ -166,5 +302,112 @@ class Session { await this.send("Input.dispatchKeyEvent", { type: "keyUp", ...base }); } - close() { try { this.ws.close(); } catch {} } + // A private, pixels-and-DOM "frame" of the browser target. It never raises + // a native window, draws OCR boxes, moves the pointer, or mutates the page. + // Pathfinding agents get the controls and React Flow topology they actually + // need in one bounded call instead of repeatedly guessing selectors. + async frame() { + return this.eval(`(() => { + const clean = (value, limit = 140) => String(value || '') + .replace(/\\s+/g, ' ').trim().slice(0, limit); + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && + Number(style.opacity || 1) > 0 && rect.width > 0 && rect.height > 0 && + rect.bottom >= 0 && rect.right >= 0 && rect.top <= innerHeight && rect.left <= innerWidth; + }; + const box = (element) => { + const rect = element.getBoundingClientRect(); + return { + x: Math.round(rect.left), y: Math.round(rect.top), + width: Math.round(rect.width), height: Math.round(rect.height), + cx: Math.round(rect.left + rect.width / 2), + cy: Math.round(rect.top + rect.height / 2), + }; + }; + const locator = (element) => { + if (element.id) return '#' + CSS.escape(element.id); + const testid = element.getAttribute('data-testid'); + if (testid) return '[data-testid=' + JSON.stringify(testid) + ']'; + const aria = element.getAttribute('aria-label'); + if (aria) return '[aria-label=' + JSON.stringify(aria) + ']'; + const role = element.getAttribute('role'); + const label = clean(element.innerText || element.textContent, 80); + if (label && ['BUTTON', 'A'].includes(element.tagName)) return 'text=' + label; + return role ? '[role=' + JSON.stringify(role) + ']' : element.tagName.toLowerCase(); + }; + const describe = (element) => ({ + tag: element.tagName.toLowerCase(), + role: element.getAttribute('role') || '', + text: clean(element.innerText || element.textContent), + ariaLabel: clean(element.getAttribute('aria-label')), + placeholder: clean(element.getAttribute('placeholder')), + testId: element.getAttribute('data-testid') || '', + disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true'), + locator: locator(element), + rect: box(element), + }); + const controls = [...document.querySelectorAll( + 'button,a,input,textarea,select,[role=button],[role=option],[role=menuitem],[role=dialog]' + )].filter(visible).slice(0, 180).map(describe); + const nodes = [...document.querySelectorAll('.react-flow__node')] + .filter(visible).map((node) => ({ + id: node.getAttribute('data-id') || node.id || '', + type: [...node.classList].find((name) => name.startsWith('react-flow__node-')) + ?.replace('react-flow__node-', '') || '', + text: clean(node.innerText || node.textContent, 220), + rect: box(node), + handles: [...node.querySelectorAll('.react-flow__handle')].filter(visible).map((handle) => ({ + id: handle.getAttribute('data-handleid') || handle.getAttribute('data-nodeid') || '', + kind: handle.classList.contains('source') ? 'source' + : handle.classList.contains('target') ? 'target' : '', + position: ['left','right','top','bottom'].find((side) => handle.classList.contains(side)) || '', + ariaLabel: clean(handle.getAttribute('aria-label')), + rect: box(handle), + })), + })); + const focus = document.activeElement && document.activeElement !== document.body + ? describe(document.activeElement) : null; + return { + schema: 'captutor-cdp-frame/v1', + capturedAt: new Date().toISOString(), + url: location.href, + title: document.title, + readyState: document.readyState, + viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio }, + focus, + controls, + graph: { + present: Boolean(document.querySelector('.react-flow')), + nodeCount: nodes.length, + edgeCount: document.querySelectorAll('.react-flow__edge').length, + nodes, + }, + }; + })()`); + } + + // Page.captureScreenshot reads the compositor invisibly. Unlike fleet Frame, + // it cannot create an on-screen overlay that leaks into a subsequent take. + async screenshot({ format = "png", quality } = {}) { + await this.send("Page.enable"); + const params = { format, fromSurface: true, captureBeyondViewport: false }; + if (format === "jpeg" && quality != null) params.quality = quality; + const { data } = await this.send("Page.captureScreenshot", params); + return Buffer.from(data, "base64"); + } + + async close({ timeoutMs = 1000 } = {}) { + clearTimeout(this.idleTimer); + this.closing = true; + try { await this.ready; } catch { return; } + if (!this.ws || this.ws.readyState >= 2) return; + const closed = new Promise((resolve) => this.ws.addEventListener("close", resolve, { once: true })); + try { this.ws.close(); } catch { return; } + await Promise.race([ + closed, + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + } } diff --git a/captutor/lib/cursor.mjs b/captutor/lib/cursor.mjs index b3e2f85e6..938f73f42 100644 --- a/captutor/lib/cursor.mjs +++ b/captutor/lib/cursor.mjs @@ -1,87 +1,83 @@ // cursor — the pointer the viewer actually sees. // // CDP clicks do not move the macOS cursor, while `reel` films the real screen. -// Stage Mode therefore drives the native pointer through a tiny smooth-motion -// helper and lands the trusted CDP click at the same page coordinate. Outside -// Stage Mode the original shadow-DOM tutorial pointer remains available. +// A small native Swift overlay paints the filmed pointer and its particles. +// Browser interaction stays on Chrome's trusted CDP channel, so presentation +// never changes hit-testing or becomes browser-bound markup. // -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; const REAL_CURSOR = process.env.CAPTUTOR_REAL_CURSOR === "1"; const POINTER_BIN = process.env.CAPTUTOR_POINTER || join(homedir(), ".local", "bin", "captutor-pointer"); +const NATIVE_CURSOR_BIN = process.env.CAPTUTOR_NATIVE_CURSOR + || join(homedir(), ".local", "bin", "captutor-cursor"); +const NO_CURSOR = process.env.CAPTUTOR_CURSOR === "none"; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -// Everything lives in a shadow-DOM host at the top layer, so fuser's own styles -// can neither restyle it nor stack above it, and it is pointer-events:none so it -// can never eat the very click it is illustrating. +let nativeCursor = null; +let nativeCursorFailure = null; +let nativeCursorReady = null; -export const INSTALL = `(() => { - if (window.__captutor) return true; - const host = document.createElement('div'); - host.id = '__captutor_cursor'; - Object.assign(host.style, { - position: 'fixed', inset: '0', pointerEvents: 'none', zIndex: '2147483647', +export async function startNativeCursor() { + if (REAL_CURSOR || NO_CURSOR) return; + if (nativeCursor) return nativeCursorReady; + if (!existsSync(NATIVE_CURSOR_BIN)) { + throw new Error( + `native Captutor cursor is not installed at ${NATIVE_CURSOR_BIN}; enter Stage Mode or run captutor/bin/install.sh`, + ); + } + nativeCursorFailure = null; + const child = spawn(NATIVE_CURSOR_BIN, [], { + stdio: ["pipe", "pipe", "inherit"], }); - document.documentElement.appendChild(host); - const root = host.attachShadow({ mode: 'open' }); - root.innerHTML = \` - - - - -
\`; - const ptr = root.querySelector('.p'); - const ring = root.querySelector('.r'); - - const state = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; - const put = (x, y) => { - state.x = x; state.y = y; - ptr.style.transform = \`translate(\${x - 3}px, \${y - 3}px)\`; - ring.style.transform = \`translate(\${x}px, \${y}px)\`; - }; - put(state.x, state.y); + nativeCursor = child; + nativeCursorReady = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("native Captutor cursor did not become ready")), 3000); + child.stdout.once("data", (chunk) => { + clearTimeout(timeout); + if (String(chunk).includes("ready")) resolve(); + else reject(new Error("native Captutor cursor returned an invalid readiness response")); + }); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + }); + child.once("error", (error) => { + if (nativeCursor === child) nativeCursorFailure = error; + }); + child.once("exit", (code, signal) => { + if (nativeCursor === child) { + if (code && !nativeCursorFailure) { + nativeCursorFailure = new Error(`native Captutor cursor exited (${code}${signal ? `, ${signal}` : ""})`); + } + nativeCursor = null; + nativeCursorReady = null; + } + }); + return nativeCursorReady; +} - // easeInOutCubic — accelerate away, coast, settle. A linear glide looks - // robotic; this reads as a hand. - const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); +function nativeCommand(op, values = {}) { + if (REAL_CURSOR || NO_CURSOR) return; + if (nativeCursorFailure) throw nativeCursorFailure; + if (!nativeCursor?.stdin?.writable) throw new Error("native Captutor cursor is unavailable"); + nativeCursor.stdin.write(`${JSON.stringify({ op, ...values })}\n`); +} - window.__captutor = { - pos: () => ({ x: state.x, y: state.y }), - moveTo: (x, y, ms = 520) => new Promise((done) => { - const x0 = state.x, y0 = state.y, t0 = performance.now(); - const step = (now) => { - const t = Math.min(1, (now - t0) / ms); - const k = ease(t); - put(x0 + (x - x0) * k, y0 + (y - y0) * k); - t < 1 ? requestAnimationFrame(step) : done(); - }; - requestAnimationFrame(step); - }), - ripple: () => new Promise((done) => { - ring.style.transition = 'none'; - ring.style.opacity = '1'; - ring.style.transform = \`translate(\${state.x}px, \${state.y}px) scale(.5)\`; - requestAnimationFrame(() => { - ring.style.transition = 'transform .42s ease-out, opacity .42s ease-out'; - ring.style.opacity = '0'; - ring.style.transform = \`translate(\${state.x}px, \${state.y}px) scale(2.6)\`; - setTimeout(done, 420); - }); - }), - }; - return true; -})()`; +export function stopNativeCursor() { + if (!nativeCursor) return; + if (nativeCursor.stdin.writable) { + nativeCursor.stdin.write(`${JSON.stringify({ op: "hide" })}\n`); + nativeCursor.stdin.write(`${JSON.stringify({ op: "quit" })}\n`); + nativeCursor.stdin.end(); + } + nativeCursor = null; + nativeCursorReady = null; +} async function moveRealPointer(cdp, point, durationMs) { const geometry = await cdp.eval(`({ @@ -90,14 +86,30 @@ async function moveRealPointer(cdp, point, durationMs) { moveRealPointerWithGeometry(geometry, point, durationMs); } -function moveRealPointerWithGeometry(geometry, point, durationMs) { +export function pagePointToScreen(geometry, point) { const borderX = Math.max(0, (geometry.outerWidth - geometry.innerWidth) / 2); const chromeY = Math.max(0, geometry.outerHeight - geometry.innerHeight - borderX); - const x = geometry.screenX + borderX + point.x; - const y = geometry.screenY + chromeY + point.y; + return { + x: geometry.screenX + borderX + point.x, + y: geometry.screenY + chromeY + point.y, + }; +} + +function moveRealPointerWithGeometry(geometry, point, durationMs) { + const { x, y } = pagePointToScreen(geometry, point); execFileSync(POINTER_BIN, [String(x), String(y), String(durationMs)]); } +async function moveNativePointer(cdp, point, durationMs, geometry = null) { + await startNativeCursor(); + const measured = geometry || await cdp.eval(`({ + screenX, screenY, outerWidth, outerHeight, innerWidth, innerHeight + })`); + const screen = pagePointToScreen(measured, point); + nativeCommand("move", { x: screen.x, y: screen.y, durationMs }); + await sleep(durationMs); +} + async function pointWithin(cdp, selector, { anchorX = 0.5, anchorY = 0.5 } = {}) { // Let Session.center provide its normal wait/retry behavior first. Anchored // points are intentionally CSS-only: text=/js= selectors name an element by @@ -118,16 +130,13 @@ async function pointWithin(cdp, selector, { anchorX = 0.5, anchorY = 0.5 } = {}) })()`); } -/// Glide the drawn pointer to an element, ripple, and land a TRUSTED click at -/// the same spot. The ripple fires just before the real click so the highlight -/// is already blooming when the UI reacts — click-then-ripple reads as lag. +/// Glide the native pointer to an element and land a TRUSTED click at the same +/// tip coordinate. Swift adds the small visual response after the UI commits. export async function clickOn( cdp, selector, { moveMs = 520, settleMs = 140, anchorX = 0.5, anchorY = 0.5 } = {}, ) { - if (!REAL_CURSOR) await cdp.eval(INSTALL); - // Measure, glide, then MEASURE AGAIN before committing the click. // // The glide takes ~half a second, and half a second is a long time in a React @@ -143,22 +152,25 @@ export async function clickOn( // Let it drain before the trusted CDP click, or that late event can land on // the canvas pane and immediately clear the node selection we just made. await new Promise((resolve) => setTimeout(resolve, 180)); - } - else await cdp.eval(`window.__captutor.moveTo(${first.x}, ${first.y}, ${moveMs})`); + } else if (!NO_CURSOR) await moveNativePointer(cdp, first, moveMs); const now = await pointWithin(cdp, selector, { anchorX, anchorY }); if (Math.hypot(now.x - first.x, now.y - first.y) > 2) { if (REAL_CURSOR) { await moveRealPointer(cdp, now, 120); await new Promise((resolve) => setTimeout(resolve, 180)); - } - else await cdp.eval(`window.__captutor.moveTo(${now.x}, ${now.y}, 120)`); + } else if (!NO_CURSOR) await moveNativePointer(cdp, now, 120); } - if (!REAL_CURSOR) await cdp.eval(`window.__captutor.ripple()`); - await cdp.mouse("mouseMoved", now.x, now.y); - await cdp.mouse("mousePressed", now.x, now.y); - await cdp.mouse("mouseReleased", now.x, now.y); + nativeCommand("down"); + try { + await cdp.mouse("mouseMoved", now.x, now.y); + await cdp.mouse("mousePressed", now.x, now.y); + await cdp.mouse("mouseReleased", now.x, now.y); + } finally { + nativeCommand("up"); + } + nativeCommand("click"); await new Promise((r) => setTimeout(r, settleMs)); return now; // where the action landed — the vertical cut crops to follow it } @@ -170,7 +182,6 @@ export async function pointAt( selector, { moveMs = 620, anchorX = 0.5, anchorY = 0.5, offsetX = 0, offsetY = 0 } = {}, ) { - if (!REAL_CURSOR) await cdp.eval(INSTALL); const base = selector.startsWith("text=") || selector.startsWith("js=") ? await cdp.center(selector) : await pointWithin(cdp, selector, { anchorX, anchorY }); @@ -180,7 +191,7 @@ export async function pointAt( const x = Math.max(12, Math.min(viewport.width - 28, base.x + Number(offsetX))); const y = Math.max(12, Math.min(viewport.height - 28, base.y + Number(offsetY))); if (REAL_CURSOR) await moveRealPointer(cdp, { x, y }, moveMs); - else await cdp.eval(`window.__captutor.moveTo(${x}, ${y}, ${moveMs})`); + else if (!NO_CURSOR) await moveNativePointer(cdp, { x, y }, moveMs); // Keep the page's pointer state aligned with the native pointer. Besides // making hover treatments truthful, this gives ScreenCaptureKit a compositor // change to record during an otherwise static, pointer-only beat. @@ -198,52 +209,63 @@ export async function dragBetween( toSelector, { moveMs = 520, dragMs = 760, steps = 24, settleMs = 220 } = {}, ) { - if (!REAL_CURSOR) await cdp.eval(INSTALL); - const first = await cdp.center(fromSelector); if (REAL_CURSOR) await moveRealPointer(cdp, first, moveMs); - else await cdp.eval(`window.__captutor.moveTo(${first.x}, ${first.y}, ${moveMs})`); + else if (!NO_CURSOR) await moveNativePointer(cdp, first, moveMs); // Re-measure after the pointer arrives. Hovering a Fuser socket enlarges it, // and React can settle the target node during the approach. const from = await cdp.center(fromSelector, { waitMs: 1500 }); const to = await cdp.center(toSelector, { waitMs: 1500 }); - const geometry = REAL_CURSOR + const geometry = (REAL_CURSOR || !NO_CURSOR) ? await cdp.eval(`({ screenX, screenY, outerWidth, outerHeight, innerWidth, innerHeight })`) : null; await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: from.x, y: from.y, button: "left", buttons: 0, }); - await cdp.send("Input.dispatchMouseEvent", { - type: "mousePressed", x: from.x, y: from.y, - button: "left", buttons: 1, clickCount: 1, - }); + nativeCommand("down"); + let released = false; + try { + await cdp.send("Input.dispatchMouseEvent", { + type: "mousePressed", x: from.x, y: from.y, + button: "left", buttons: 1, clickCount: 1, + }); - const ease = (t) => (t < 0.5 - ? 4 * t * t * t - : 1 - Math.pow(-2 * t + 2, 3) / 2); - for (let index = 1; index <= steps; index += 1) { - const k = ease(index / steps); - const point = { - x: from.x + (to.x - from.x) * k, - y: from.y + (to.y - from.y) * k, - }; - if (REAL_CURSOR) { - moveRealPointerWithGeometry(geometry, point, Math.max(12, dragMs / steps)); - } else { - await cdp.eval(`window.__captutor.moveTo(${point.x}, ${point.y}, ${Math.max(12, dragMs / steps)})`); + const ease = (t) => (t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2); + for (let index = 1; index <= steps; index += 1) { + const k = ease(index / steps); + const point = { + x: from.x + (to.x - from.x) * k, + y: from.y + (to.y - from.y) * k, + }; + if (REAL_CURSOR) { + moveRealPointerWithGeometry(geometry, point, Math.max(12, dragMs / steps)); + } else if (!NO_CURSOR) { + await moveNativePointer(cdp, point, Math.max(12, dragMs / steps), geometry); + } + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseMoved", x: point.x, y: point.y, + button: "left", buttons: 1, + }); } + await cdp.send("Input.dispatchMouseEvent", { - type: "mouseMoved", x: point.x, y: point.y, - button: "left", buttons: 1, + type: "mouseReleased", x: to.x, y: to.y, + button: "left", buttons: 0, clickCount: 1, }); + released = true; + } finally { + if (!released) { + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseReleased", x: to.x, y: to.y, + button: "left", buttons: 0, clickCount: 1, + }).catch(() => {}); + } + nativeCommand("up"); } - - await cdp.send("Input.dispatchMouseEvent", { - type: "mouseReleased", x: to.x, y: to.y, - button: "left", buttons: 0, clickCount: 1, - }); await new Promise((resolve) => setTimeout(resolve, settleMs)); return { from, to }; } diff --git a/captutor/lib/onboarding.mjs b/captutor/lib/onboarding.mjs new file mode 100644 index 000000000..3b9eacd41 --- /dev/null +++ b/captutor/lib/onboarding.mjs @@ -0,0 +1,149 @@ +// onboarding — the shared DOM/bridge contract between Fuser tutorials and Captutor. +// +// Product code owns tutorial IDs, ordering, requirements, and state. Captutor +// discovers that contract from semantic data attributes and the local-only +// `window.__fuserOnboardingAudit` bridge. This keeps screenplays away from +// translated copy, generated class names, portal structure, and Zustand internals. + +const ACTIVE_OVERLAY = '[data-onboarding-overlay]'; +const ACTIVE_TARGET = '[data-onboarding-active="true"]'; +const INITIAL_DIALOG = '[data-onboarding-surface="initial-dialog"]'; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const visibleAction = action => `js=(() => { + const roots = [ + document.querySelector(${JSON.stringify(ACTIVE_OVERLAY)}), + document.querySelector(${JSON.stringify(INITIAL_DIALOG)}), + document, + ].filter(Boolean); + for (const root of roots) { + const button = [...root.querySelectorAll( + '[data-onboarding-action=${JSON.stringify(action)}]' + )].find(element => element.getClientRects().length > 0 && !element.disabled); + if (button) return button; + } + return null; +})()`; + +export const onboardingSelectors = Object.freeze({ + activeOverlay: ACTIVE_OVERLAY, + activeTarget: ACTIVE_TARGET, + initialDialog: INITIAL_DIALOG, + replayControl: '[data-neo-anchor="replay-editor-tutorial"]', + nextAction: visibleAction('next'), + finishAction: visibleAction('finish'), + previousAction: visibleAction('previous'), +}); + +export async function readOnboardingState(cdp) { + return cdp.eval(`(() => { + const dialog = document.querySelector(${JSON.stringify(INITIAL_DIALOG)}); + const overlay = document.querySelector(${JSON.stringify(ACTIVE_OVERLAY)}); + const target = document.querySelector(${JSON.stringify(ACTIVE_TARGET)}); + const bridge = window.__fuserOnboardingAudit; + const store = bridge?.snapshot?.() ?? null; + const source = overlay || dialog; + return { + contractVersion: bridge?.version ?? null, + engine: overlay?.dataset.onboardingEngine || (dialog ? 'classic-dialog' : 'classic'), + step: source?.dataset.onboardingOverlay || source?.dataset.onboardingStep || + store?.currentStepper || null, + group: target?.dataset.onboardingGroup || null, + contentIndex: Number( + source?.dataset.onboardingContentIndex ?? store?.currentStepperContentIndex ?? 0, + ), + requirement: overlay?.dataset.onboardingRequirement || null, + requirementMet: overlay?.dataset.onboardingRequirementMet === 'true', + actions: [...document.querySelectorAll('[data-onboarding-action]')] + .filter(element => element.getClientRects().length > 0 && !element.disabled) + .map(element => element.dataset.onboardingAction), + targetAnchor: target?.dataset.neoAnchor || null, + store, + }; + })()`); +} + +export async function waitForOnboardingStep( + cdp, + { step, contentIndex, timeoutMs = 20000 } = {}, +) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + last = await readOnboardingState(cdp); + const stepMatches = step === undefined || last.step === step; + const indexMatches = + contentIndex === undefined || last.contentIndex === contentIndex; + if (last.step && stepMatches && indexMatches) return last; + await sleep(100); + } + throw new Error( + `onboarding step timed out: expected ${JSON.stringify({ step, contentIndex })}, ` + + `last ${JSON.stringify(last)}`, + ); +} + +export async function satisfyOnboardingRequirement(cdp, requirement) { + const result = await cdp.eval(`(() => { + if (!['localhost', '127.0.0.1'].includes(location.hostname)) { + return { ok:false, reason:'audit bridge is local-only' }; + } + const bridge = window.__fuserOnboardingAudit; + if (!bridge || bridge.version !== 1) { + return { ok:false, reason:'onboarding audit bridge v1 is unavailable' }; + } + bridge.completeRequirement(${JSON.stringify(requirement)}); + return { ok:true }; + })()`); + if (!result?.ok) throw new Error(result?.reason || 'could not satisfy requirement'); + return result; +} + +export async function advanceOnboarding( + cdp, + { click, satisfyRequirements = false, timeoutMs = 20000 } = {}, +) { + if (typeof click !== 'function') throw new Error('advanceOnboarding needs Captutor click'); + const before = await waitForOnboardingStep(cdp, { timeoutMs }); + const beforeKey = `${before.step}:${before.contentIndex}`; + + if (before.requirement && !before.requirementMet) { + if (!satisfyRequirements) { + throw new Error(`onboarding step ${beforeKey} needs ${before.requirement}`); + } + await satisfyOnboardingRequirement(cdp, before.requirement); + } else if (before.actions.includes('next')) { + await click(onboardingSelectors.nextAction); + } else if (before.actions.includes('finish')) { + await click(onboardingSelectors.finishAction); + } else { + throw new Error(`onboarding step ${beforeKey} has no forward action`); + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const after = await readOnboardingState(cdp); + if (!after.step || `${after.step}:${after.contentIndex}` !== beforeKey) { + return { before, after }; + } + await sleep(100); + } + throw new Error(`onboarding did not advance from ${beforeKey}`); +} + +export async function startOnboardingReplay(cdp) { + const result = await cdp.eval(`(() => { + if (!['localhost', '127.0.0.1'].includes(location.hostname)) { + return { ok:false, reason:'audit bridge is local-only' }; + } + const bridge = window.__fuserOnboardingAudit; + if (!bridge || bridge.version !== 1) { + return { ok:false, reason:'onboarding audit bridge v1 is unavailable' }; + } + bridge.startReplay(); + return { ok:true }; + })()`); + if (!result?.ok) throw new Error(result?.reason || 'could not start replay'); + return waitForOnboardingStep(cdp); +} diff --git a/captutor/lib/stage-contract.mjs b/captutor/lib/stage-contract.mjs new file mode 100644 index 000000000..5f90539cc --- /dev/null +++ b/captutor/lib/stage-contract.mjs @@ -0,0 +1,52 @@ +// Production fleet missions pathfind on the ordinary desktop, but a real take +// must run inside Captutor's reversible 2x Stage profile. Keep this contract +// separate from the stage setup itself so the recorder can fail closed using +// what Chrome actually sees, rather than trusting an environment flag alone. + +export class StageContractError extends Error { + constructor(message, details = {}) { + super(message); + this.name = "StageContractError"; + this.code = "CAPTUTOR_HIDPI_STAGE_REQUIRED"; + this.details = details; + } +} + +export function assertHiDPIStage({ + required = false, + stageMode = false, + vertical = false, + screen = null, +} = {}) { + if (!required) return null; + if (!stageMode) { + throw new StageContractError( + "fleet mission takes must render through `node bin/stage.mjs render ...`", + { stageMode, vertical }, + ); + } + + // The first assertion can run before CDP attaches. Once Chrome is available, + // the second assertion proves Stage changed the real display to the expected + // logical canvas backed by at least 2x device pixels. + if (!screen) return { stageMode, vertical }; + + const expected = vertical + ? { width:720, height:1280 } + : { width:1280, height:720 }; + const actual = { + width:Number(screen.width), + height:Number(screen.height), + dpr:Number(screen.dpr), + }; + if (actual.width !== expected.width + || actual.height !== expected.height + || !Number.isFinite(actual.dpr) + || actual.dpr < 1.75) { + throw new StageContractError( + `HiDPI Stage is not active (expected ${expected.width}x${expected.height} at 2x; got ${actual.width}x${actual.height} at ${actual.dpr}x)`, + { stageMode, vertical, expected, actual }, + ); + } + return actual; +} diff --git a/captutor/lib/stage-mode.mjs b/captutor/lib/stage-mode.mjs index deb293f88..21e6cc6e3 100644 --- a/captutor/lib/stage-mode.mjs +++ b/captutor/lib/stage-mode.mjs @@ -14,15 +14,41 @@ const SIGILS_OFF = join(HOME, ".local", "share", "slab", "state", "prompt-sigils const BADGE_PLIST = join(HOME, "Library", "LaunchAgents", "computer.aesthetic.desktopbadge.plist"); const POINTER_SOURCE = fileURLToPath(new URL("../bin/captutor-pointer.swift", import.meta.url)); const POINTER_BIN = join(HOME, ".local", "bin", "captutor-pointer"); +const CURSOR_SOURCE = fileURLToPath(new URL("../bin/captutor-cursor.swift", import.meta.url)); +const CURSOR_BIN = join(HOME, ".local", "bin", "captutor-cursor"); const WALLPAPER_SOURCE = process.env.CAPTUTOR_WALLPAPER_SOURCE || fileURLToPath(new URL("../bin/captutor-wallpaper.swift", import.meta.url)); const WALLPAPER_LOGO = fileURLToPath(new URL("../assets/fuser-thumbnail-logo.svg", import.meta.url)); +const WALLPAPER_MARK = fileURLToPath(new URL("../assets/fuser-mark.svg", import.meta.url)); +const WALLPAPER_METABALLS = [0, 1, 2].map((variant) => + fileURLToPath(new URL(`../assets/fuser-metaballs-${variant}.png`, import.meta.url))); const WALLPAPER_APP = join(HOME, ".local", "share", "captutor", "Captutor Wallpaper.app"); const WALLPAPER_BIN = join(WALLPAPER_APP, "Contents", "MacOS", "CaptutorWallpaper"); const WALLPAPER_STATE = join(HOME, ".local", "share", "captutor", "wallpaper-card.json"); const WALLPAPER = "/System/Library/Desktop Pictures/Solid Colors/Space Gray.png"; const DISPLAYPLACER = "/opt/homebrew/bin/displayplacer"; +export function normalizeStageBrand(value = "fuser") { + const brand = String(value || "fuser").trim().toLowerCase(); + if (brand !== "fuser" && brand !== "classic") { + throw new Error(`unsupported Captutor Stage brand "${value}"; expected fuser or classic`); + } + return brand; +} + +export function parseStageFlags(rawArgs = []) { + const vertical = rawArgs.includes("--vertical"); + const brandIndex = rawArgs.indexOf("--brand"); + if (brandIndex >= 0 && (!rawArgs[brandIndex + 1] || rawArgs[brandIndex + 1].startsWith("--"))) { + throw new Error("--brand needs a value: fuser or classic"); + } + const brand = normalizeStageBrand(brandIndex >= 0 ? rawArgs[brandIndex + 1] : "fuser"); + const args = rawArgs.filter((arg, index) => + arg !== "--vertical" && + (brandIndex < 0 || (index !== brandIndex && index !== brandIndex + 1))); + return { vertical, brand, args }; +} + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const run = (file, args = [], { allowFailure = false, timeout } = {}) => { const result = spawnSync(file, args, { encoding: "utf8", timeout }); @@ -41,6 +67,11 @@ function readDefault(domain, key) { return result.status === 0 ? result.stdout.trim() : null; } +function currentPointerSize() { + const value = Number(readDefault("com.apple.universalaccess.plist", "mouseDriverCursorSize")); + return Number.isFinite(value) ? value : 1; +} + function restoreBoolean(domain, key, value) { if (value == null) run("/usr/bin/defaults", ["delete", domain, key], { allowFailure: true }); else run("/usr/bin/defaults", ["write", domain, key, "-bool", /^(1|true|yes)$/i.test(value) ? "true" : "false"]); @@ -97,6 +128,43 @@ function setMenuAutohide(value) { osa(`tell application "System Events" to set autohide menu bar of dock preferences to ${value ? "true" : "false"}`); } +function screenGeometry() { + return JSON.parse(swift(` +import AppKit +let screen = NSScreen.main! +let frame = screen.frame +let visible = screen.visibleFrame +print("{\\"width\\":\\(Int(frame.width)),\\"height\\":\\(Int(frame.height)),\\"visibleWidth\\":\\(Int(visible.width)),\\"visibleHeight\\":\\(Int(visible.height))}") +`)); +} + +function movePointerAwayFromMenuBar() { + swift(` +import CoreGraphics +let display = CGMainDisplayID() +let bounds = CGDisplayBounds(display) +CGWarpMouseCursorPosition(CGPoint(x: bounds.midX, y: bounds.midY)) +`); +} + +async function enforceHiddenSystemChrome() { + // The menu-bar preference is owned by SystemUIServer, not Dock. Restarting + // only Dock can leave the old bar composited into the first seconds of a + // take, and a pointer parked at y=0 keeps an auto-hidden bar revealed. + sh("killall Finder >/dev/null 2>&1 || true; killall Dock >/dev/null 2>&1 || true; killall SystemUIServer >/dev/null 2>&1 || true"); + movePointerAwayFromMenuBar(); + await sleep(1200); + if (!menuAutohide()) throw new Error("macOS did not enable menu-bar auto-hide"); + const geometry = screenGeometry(); + // A hidden Dock can reserve a tiny reveal strip, but the 30-point menu-bar + // inset must be gone before Reel is allowed to start. + if (geometry.height - geometry.visibleHeight > 8) { + throw new Error( + `system chrome still occupies the Stage (${geometry.visibleWidth}x${geometry.visibleHeight} visible inside ${geometry.width}x${geometry.height})`, + ); + } +} + function darkMode() { return osa('tell application "System Events" to tell appearance preferences to get dark mode') === "true"; } @@ -289,6 +357,12 @@ function compilePointerBridge() { run("/usr/bin/swiftc", ["-O", POINTER_SOURCE, "-o", POINTER_BIN]); } +function compileNativeCursor() { + mkdirSync(dirname(CURSOR_BIN), { recursive: true }); + run("/usr/bin/pkill", ["-x", "captutor-cursor"], { allowFailure: true }); + run("/usr/bin/swiftc", ["-O", CURSOR_SOURCE, "-o", CURSOR_BIN]); +} + function compileWallpaper() { mkdirSync(dirname(WALLPAPER_BIN), { recursive: true }); run("/usr/bin/swiftc", ["-O", WALLPAPER_SOURCE, "-o", WALLPAPER_BIN]); @@ -296,6 +370,8 @@ function compileWallpaper() { const resources = join(contents, "Resources"); mkdirSync(resources, { recursive: true }); copyFileSync(WALLPAPER_LOGO, join(resources, "fuser-thumbnail-logo.svg")); + copyFileSync(WALLPAPER_MARK, join(resources, "fuser-mark.svg")); + for (const sheet of WALLPAPER_METABALLS) copyFileSync(sheet, join(resources, sheet.split("/").pop())); writeFileSync(join(contents, "Info.plist"), ` @@ -311,22 +387,28 @@ function stopWallpaper() { run("/usr/bin/pkill", ["-x", "CaptutorWallpaper"], { allowFailure: true }); } -async function startWallpaper() { +async function startWallpaper(brand) { stopWallpaper(); - run("/usr/bin/open", ["-na", WALLPAPER_APP]); + run("/usr/bin/open", ["-na", WALLPAPER_APP, "--args", "--brand", normalizeStageBrand(brand)]); await sleep(900); if (spawnSync("/usr/bin/pgrep", ["-x", "CaptutorWallpaper"]).status !== 0) { throw new Error("Captutor dynamic wallpaper did not launch"); } } -export async function enterStageMode({ vertical = process.env.CAPTUTOR_VERTICAL_MODE === "1" } = {}) { +export async function enterStageMode({ + vertical = process.env.CAPTUTOR_VERTICAL_MODE === "1", + brand = process.env.CAPTUTOR_BRAND || "fuser", +} = {}) { + brand = normalizeStageBrand(brand); + const filmingRealCursor = process.env.CAPTUTOR_REAL_CURSOR === "1"; if (existsSync(STATE)) await exitStageMode(); - const cursorSize = Number(readDefault("com.apple.universalaccess.plist", "mouseDriverCursorSize") || 1); + const cursorSize = currentPointerSize(); const state = { displayMode: displayModeID(), displayProfile: displayProfile(), vertical, + brand, wallpaper: osa('tell application "System Events" to get picture of desktop 1 as text'), createDesktop: readDefault("com.apple.finder", "CreateDesktop"), dockAutohide: readDefault("com.apple.dock", "autohide"), @@ -337,7 +419,10 @@ export async function enterStageMode({ vertical = process.env.CAPTUTOR_VERTICAL_ visibleApps: visibleApps(), sigilsWereOff: existsSync(SIGILS_OFF), cursorSize, - pointerChanged: process.env.CAPTUTOR_STAGE_KEEP_POINTER !== "1", + // The default filmed cursor is our Swift overlay, so the system pointer is + // excluded by Reel and does not need a privileged Accessibility-settings + // detour. Preserve enlargement only for explicitly human-driven takes. + pointerChanged: filmingRealCursor && process.env.CAPTUTOR_STAGE_KEEP_POINTER !== "1", }; mkdirSync(dirname(STATE), { recursive: true }); writeFileSync(STATE, JSON.stringify(state, null, 2)); @@ -354,6 +439,7 @@ export async function enterStageMode({ vertical = process.env.CAPTUTOR_VERTICAL_ if (darkMode()) throw new Error("macOS did not enter Light appearance"); compilePointerBridge(); + compileNativeCursor(); compileWallpaper(); if (vertical) configureVerticalDisplay(); else if (process.env.CAPTUTOR_STAGE_KEEP_DISPLAY !== "1") configureDisplay(); @@ -361,24 +447,41 @@ export async function enterStageMode({ vertical = process.env.CAPTUTOR_VERTICAL_ run("/usr/bin/defaults", ["write", "com.apple.finder", "CreateDesktop", "-bool", "false"]); run("/usr/bin/defaults", ["write", "com.apple.dock", "autohide", "-bool", "true"]); setMenuAutohide(true); - sh("killall Finder >/dev/null 2>&1 || true; killall Dock >/dev/null 2>&1 || true"); + await enforceHiddenSystemChrome(); mkdirSync(dirname(SIGILS_OFF), { recursive: true }); writeFileSync(SIGILS_OFF, ""); if (state.badgeLoaded) run("/bin/launchctl", ["bootout", `gui/${process.getuid()}/computer.aesthetic.desktopbadge`], { allowFailure: true }); if (state.statsRunning) osa('tell application "Stats" to quit', [], true); - if (state.pointerChanged) await setPointerSizeWithRetry(cursorSize, 1.5); + if (state.pointerChanged) { + try { + await setPointerSizeWithRetry(cursorSize, 1.5); + } catch (error) { + // Pointer enlargement is filming polish, not a recording permission. + // New System Settings builds can keep AX access enabled while omitting + // AX_CURSOR_SIZE from the live tree. Continue only if the failed attempt + // left the saved cursor preference untouched; otherwise fail so cleanup + // retains responsibility for restoring it. + if (Math.abs(currentPointerSize() - cursorSize) > 0.08) throw error; + state.pointerChanged = false; + writeFileSync(STATE, JSON.stringify(state, null, 2)); + console.warn(`! pointer enlargement unavailable; filming with saved ${cursorSize}× cursor`); + } + } hideOtherApps(); writeFileSync(WALLPAPER_STATE, JSON.stringify({ phase: "ambient" })); - await startWallpaper(); + await startWallpaper(brand); osa('tell application "Google Chrome" to activate', [], true); console.log( `✓ Captutor ${vertical ? "Vertical " : ""}Stage Mode active — ` + `Light, ${process.env.CAPTUTOR_STAGE_KEEP_DISPLAY === "1" ? "native display" : "2× HiDPI"}, ` + - `branded desk, real ~1.5× pointer`, + `${brand} desk, hidden system chrome, ${filmingRealCursor ? "real ~1.5×" : "native Swift"} pointer`, ); } export async function exitStageMode() { + // A crashed renderer may not get a chance to close its stdin. Never let its + // filmed pointer survive onto the operator's restored desktop. + run("/usr/bin/pkill", ["-x", "captutor-cursor"], { allowFailure: true }); if (!existsSync(STATE)) return; const state = JSON.parse(readFileSync(STATE, "utf8")); const failures = []; @@ -391,7 +494,13 @@ export async function exitStageMode() { // display in HiDPI, and a failed pointer drag must never keep Stats hidden. await restore("dynamic wallpaper", () => stopWallpaper()); if (state.pointerChanged !== false) { - await restore("pointer", () => setPointerSize(1.5, state.cursorSize ?? 1)); + await restore("pointer", async () => { + const wanted = Number(state.cursorSize ?? 1); + // An interrupted entry may have failed before changing the preference. + // Do not reopen System Settings merely to restore a value already exact. + if (Math.abs(currentPointerSize() - wanted) <= 0.08) return; + await setPointerSize(1.5, wanted); + }); } await restore("prompt sigils", () => { if (!state.sigilsWereOff) rmSync(SIGILS_OFF, { force: true }); @@ -409,7 +518,7 @@ export async function exitStageMode() { await restore("desktop icons", () => restoreBoolean("com.apple.finder", "CreateDesktop", state.createDesktop)); await restore("Dock", () => restoreBoolean("com.apple.dock", "autohide", state.dockAutohide)); await restore("menu bar", () => setMenuAutohide(Boolean(state.menuAutohide))); - await restore("Finder and Dock", () => sh("killall Finder >/dev/null 2>&1 || true; killall Dock >/dev/null 2>&1 || true")); + await restore("Finder, Dock, and menu bar", () => sh("killall Finder >/dev/null 2>&1 || true; killall Dock >/dev/null 2>&1 || true; killall SystemUIServer >/dev/null 2>&1 || true")); await restore("display", () => { if (state.displayProfile && existsSync(DISPLAYPLACER)) { run(DISPLAYPLACER, [state.displayProfile]); diff --git a/captutor/lib/tutorial-layout.mjs b/captutor/lib/tutorial-layout.mjs new file mode 100644 index 000000000..a45e924f4 --- /dev/null +++ b/captutor/lib/tutorial-layout.mjs @@ -0,0 +1,272 @@ +// Interface-aware composition for filmed React Flow tutorials. +// +// Browser controls can exist outside the page DOM (for example Chrome's +// bottom "Continue the chat" affordance), so mathematical canvas centering is +// not necessarily centered in the view a learner sees. These helpers reserve +// a stable safe region, widen tutorial nodes, and score legibility and balance. + +export const TUTORIAL_LAYOUT_STYLE_ID = "captutor-tutorial-layout"; +export const TUTORIAL_NODE_WIDTH = 336; +export const TUTORIAL_SAFE_INSETS = Object.freeze({ + left: 88, + top: 76, + right: 72, + bottom: 72, +}); + +const clamp = (value, low = 0, high = 100) => Math.max(low, Math.min(high, value)); + +export async function installTutorialLayout(cdp, selectors, { + nodeWidth = TUTORIAL_NODE_WIDTH, +} = {}) { + const css = selectors + .map((selector) => `${selector} { width: ${nodeWidth}px !important; } +${selector} .w-1\\/2:has(input[aria-label="flow.nodes.FalGeminiImageNode.inputs.model.label"]) { + width: 65% !important; +} +${selector} .w-1\\/2:has(input[aria-label="flow.nodes.FalGeminiImageNode.inputs.aspect_ratio.label"]) { + width: 35% !important; +}`) + .join("\n"); + await cdp.eval(`(() => { + const id = ${JSON.stringify(TUTORIAL_LAYOUT_STYLE_ID)}; + let style = document.getElementById(id); + if (!style) { + style = document.createElement('style'); + style.id = id; + document.head.appendChild(style); + } + style.textContent = ${JSON.stringify(css)}; + return true; + })()`); +} + +export async function removeTutorialLayout(cdp) { + await cdp.eval(`document.getElementById(${JSON.stringify(TUTORIAL_LAYOUT_STYLE_ID)})?.remove()`); +} + +function titleSelector(nodeSelector, title) { + return `js=[...document.querySelectorAll(${JSON.stringify(`${nodeSelector} *`)})] + .find((element) => element.children.length === 0 && + (element.textContent || '').trim() === ${JSON.stringify(title)})`; +} + +function targetSelector(nodeSelector, index, count, options) { + return `js=(() => { + const node = document.querySelector(${JSON.stringify(nodeSelector)}); + const title = [...node.querySelectorAll('*')].find((element) => + element.children.length === 0 && (element.textContent || '').trim() === ${JSON.stringify(options.title)}); + const nodeRect = node.getBoundingClientRect(); + const titleRect = title.getBoundingClientRect(); + const chatButton = [...document.querySelectorAll('button')].find((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && + (element.innerText || '').trim() === 'Continue the chat'; + }); + const chatTop = chatButton?.getBoundingClientRect().top; + const safe = { + left: ${options.insets.left}, + top: ${options.insets.top}, + right: innerWidth - ${options.insets.right}, + bottom: Math.min( + innerHeight - ${options.insets.bottom}, + Number.isFinite(chatTop) ? chatTop - 20 : innerHeight, + ), + }; + const safeWidth = safe.right - safe.left; + const groupWidth = nodeRect.width * ${count} + ${options.gap} * Math.max(0, ${count} - 1); + const groupLeft = safe.left + (safeWidth - groupWidth) / 2; + const desiredLeft = groupLeft + ${index} * (nodeRect.width + ${options.gap}); + const desiredTop = safe.top + Math.max(0, (safe.bottom - safe.top - nodeRect.height) / 2); + const titleOffsetX = titleRect.left + titleRect.width / 2 - nodeRect.left; + const titleOffsetY = titleRect.top + titleRect.height / 2 - nodeRect.top; + const x = desiredLeft + titleOffsetX; + const y = desiredTop + titleOffsetY; + return { + getBoundingClientRect: () => ({ left:x - 1, top:y - 1, width:2, height:2 }), + scrollIntoView() {}, + }; + })()`; +} + +// Drive React Flow's continuous pinch-zoom path rather than opening its menu. +// This lets the teaching layout land around 80% (large enough to read, small +// enough to clear the chat composer) without filming a mystery menu detour. +export async function setTutorialZoom(ctx, target = 80) { + const { cdp } = ctx; + const zoomValue = async () => Number(await cdp.eval(`(() => { + const button = [...document.querySelectorAll('button')].find((element) => + /^\\d+\\s*%$/.test((element.innerText || '').trim())); + return button ? parseInt(button.innerText, 10) : NaN; + })()`)); + + for (let attempt = 0; attempt < 5; attempt += 1) { + const current = await zoomValue(); + if (!Number.isFinite(current)) throw new Error("Fuser zoom control is unavailable"); + if (Math.abs(current - target) <= 3) return current; + // React Flow's wheel curve is approximately exp(-0.0138 * deltaY). + // Clamp extreme fit-view recovery into a few smooth, bounded events. + const deltaY = Math.max(-80, Math.min(80, + -Math.log(target / current) / 0.0138)); + const point = await cdp.eval(`(() => { + const pane = document.querySelector('.react-flow__pane'); + const rect = pane?.getBoundingClientRect(); + return rect + ? { x:rect.left + rect.width / 2, y:rect.top + rect.height / 2 } + : { x:innerWidth / 2, y:innerHeight / 2 }; + })()`); + await cdp.send("Input.dispatchMouseEvent", { + type:"mouseWheel", x:point.x, y:point.y, + deltaX:0, deltaY, modifiers:2, + }); + await cdp.waitFor(`(() => { + const button = [...document.querySelectorAll('button')].find((element) => + /^\\d+\\s*%$/.test((element.innerText || '').trim())); + return button && parseInt(button.innerText, 10) !== ${current}; + })()`); + } + throw new Error(`Could not reach tutorial zoom near ${target}%`); +} + +export async function frameTutorialNodes(ctx, nodes, { + insets = TUTORIAL_SAFE_INSETS, + gap = 64, + moveMs = 380, + dragMs = 480, +} = {}) { + const { drag, sleep } = ctx; + for (let index = 0; index < nodes.length; index += 1) { + const node = nodes[index]; + await drag( + titleSelector(node.selector, node.title), + targetSelector(node.selector, index, nodes.length, { title:node.title, insets, gap }), + { moveMs, dragMs }, + ); + await sleep(180); + } +} + +export async function tutorialLayoutScores(cdp, selectors, { + insets = TUTORIAL_SAFE_INSETS, + minimumNodeWidth = 320, + idealGap = 64, +} = {}) { + const raw = await cdp.eval(`(() => { + const chatButton = [...document.querySelectorAll('button')].find((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && + (element.innerText || '').trim() === 'Continue the chat'; + }); + const chatTop = chatButton?.getBoundingClientRect().top; + const safe = { + left:${insets.left}, top:${insets.top}, + right:innerWidth - ${insets.right}, + bottom:Math.min( + innerHeight - ${insets.bottom}, + Number.isFinite(chatTop) ? chatTop - 20 : innerHeight, + ), + }; + const nodes = ${JSON.stringify(selectors)} + .map((selector) => ({ selector, node:document.querySelector(selector) })) + .filter(({ node }) => Boolean(node)).map(({ selector, node }) => { + const rect = node.getBoundingClientRect(); + const readable = [...node.querySelectorAll('button,[role=option],input:not([type=hidden])')] + .filter((element) => { + const r = element.getBoundingClientRect(); + const text = element instanceof HTMLInputElement + ? element.value : (element.textContent || '').trim(); + return r.width > 0 && r.height > 0 && text; + }).map((element) => { + const text = (element instanceof HTMLInputElement + ? element.value : (element.textContent || '')).trim().replace(/\\s+/g, ' '); + const style = getComputedStyle(element); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context.font = [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily] + .filter(Boolean).join(' '); + const horizontalPadding = (parseFloat(style.paddingLeft) || 0) + + (parseFloat(style.paddingRight) || 0); + const measuredWidth = context.measureText(text).width + horizontalPadding; + return { + text, + clipped:measuredWidth > element.clientWidth + 2 || + element.scrollWidth > element.clientWidth + 2 || /(?:…|\\.{3})$/.test(text), + }; + }); + return { + selector, + layoutWidth:node.offsetWidth, + rect:{ left:rect.left, top:rect.top, right:rect.right, bottom:rect.bottom, + width:rect.width, height:rect.height, cx:rect.left + rect.width / 2, + cy:rect.top + rect.height / 2 }, + truncated:readable.filter((item) => item.clipped).map((item) => item.text), + }; + }); + return { viewport:{ width:innerWidth, height:innerHeight }, safe, nodes }; + })()`); + + const outside = raw.nodes.filter(({ rect }) => + rect.left < raw.safe.left || rect.right > raw.safe.right || + rect.top < raw.safe.top || rect.bottom > raw.safe.bottom); + const narrow = raw.nodes.filter(({ rect, layoutWidth }) => + (Number.isFinite(layoutWidth) ? layoutWidth : rect.width) < minimumNodeWidth); + const truncated = raw.nodes.flatMap((node) => node.truncated); + const uiScore = Math.round(clamp( + 100 - outside.length * 28 - narrow.length * 22 - truncated.length * 18, + )); + + let balancePenalty = 0; + if (raw.nodes.length) { + const left = Math.min(...raw.nodes.map(({ rect }) => rect.left)); + const right = Math.max(...raw.nodes.map(({ rect }) => rect.right)); + const top = Math.min(...raw.nodes.map(({ rect }) => rect.top)); + const bottom = Math.max(...raw.nodes.map(({ rect }) => rect.bottom)); + const groupCenter = { x:(left + right) / 2, y:(top + bottom) / 2 }; + const safeCenter = { + x:(raw.safe.left + raw.safe.right) / 2, + y:(raw.safe.top + raw.safe.bottom) / 2, + }; + balancePenalty += Math.abs(groupCenter.x - safeCenter.x) / 5; + balancePenalty += Math.abs(groupCenter.y - safeCenter.y) / 5; + if (raw.nodes.length > 1) { + const ordered = [...raw.nodes].sort((a, b) => a.rect.left - b.rect.left); + for (let index = 1; index < ordered.length; index += 1) { + const gap = ordered[index].rect.left - ordered[index - 1].rect.right; + balancePenalty += Math.abs(gap - idealGap) / 3; + balancePenalty += Math.abs(ordered[index].rect.cy - ordered[index - 1].rect.cy) / 6; + if (gap < 24) balancePenalty += 35; + } + } + } else { + balancePenalty = 100; + } + const balancedScore = Math.round(clamp(100 - balancePenalty - outside.length * 20)); + + return { + "ui-legibility-score": { + score:uiScore, + threshold:90, + truncated, + outsideSafeRegion:outside.map((node) => node.selector), + narrowNodes:narrow.map((node) => node.selector), + safeRegion:raw.safe, + }, + "balanced-layout-score": { + score:balancedScore, + threshold:88, + nodeRects:raw.nodes.map(({ selector, rect }) => ({ selector, ...rect })), + safeRegion:raw.safe, + }, + }; +} + +export async function assertTutorialLayout(ctx, selectors) { + const scores = await tutorialLayoutScores(ctx.cdp, selectors); + for (const [name, result] of Object.entries(scores)) { + ctx.check(name, result); + if (result.score < result.threshold) { + throw new Error(`${name} ${result.score} is below ${result.threshold}: ${JSON.stringify(result)}`); + } + } + return scores; +} diff --git a/captutor/ops/heartbeat.sh b/captutor/ops/heartbeat.sh new file mode 100644 index 000000000..25043100c --- /dev/null +++ b/captutor/ops/heartbeat.sh @@ -0,0 +1,36 @@ +#!/bin/zsh +# iris heartbeat — liveness plus bounded mission recovery. Runs every 5 min. +# It stamps presence, checks for a stopped Captutor mission, repairs a crashed +# Fuser renderer, and removes one local failure tombstone so the orchestrator +# can retry. The orchestrator still enforces current Asana ownership before it +# launches anything; the heartbeat cannot invent work or bypass assignment. +# +# Kill switch: +# launchctl bootout gui/$(id -u)/ai.iris.heartbeat # stop the pulse +export PATH=/opt/homebrew/bin:$PATH +LOG="$HOME/.hermes/logs/heartbeat.log" +mkdir -p "$HOME/.hermes/logs" + +# read-only liveness checks +GW=$(pgrep -f "hermes.*gateway" >/dev/null 2>&1 && echo up || echo down) +BOARD=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9120/ 2>/dev/null) + +# stamp the beat onto the board (lastHeartbeat + updatedAt), without touching items +/opt/homebrew/bin/node -e ' +const fs=require("fs"); +const p=process.env.HOME+"/.local/share/desktop-badge/mission.json"; +try{ + const d=JSON.parse(fs.readFileSync(p,"utf8")); + const now=new Date().toISOString(); + d.lastHeartbeat=now; d.updatedAt=now; + fs.writeFileSync(p, JSON.stringify(d,null,2)); +}catch(e){}' 2>/dev/null + +echo "$(date -u +%FT%TZ) ♥ beat · gateway=$GW · board=$BOARD" >> "$LOG" + +# Save a recoverable Captutor mission from a worker/browser stoppage. This is a +# strict one-retry supervisor by default, and it never draws Frame/Puppet UI. +RECOVERY="$HOME/Developer/captutor/ops/iris-heartbeat-recovery.mjs" +if [[ -f "$RECOVERY" ]]; then + /opt/homebrew/bin/node "$RECOVERY" >> "$LOG" 2>&1 +fi diff --git a/captutor/ops/iris-heartbeat-recovery.mjs b/captutor/ops/iris-heartbeat-recovery.mjs new file mode 100644 index 000000000..9f8d34ec8 --- /dev/null +++ b/captutor/ops/iris-heartbeat-recovery.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +// Active half of Iris's heartbeat. The shell heartbeat still publishes a +// liveness pulse; this supervisor repairs one recoverable Captutor stoppage. +// Ownership remains with the orchestrator: requeueing only removes its local +// failure tombstone, and the task will relaunch only if Asana still assigns it +// to Iris. + +import { execFileSync } from "node:child_process"; +import { + existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const home = homedir(); +const statePath = process.env.IRIS_ORCHESTRATOR_STATE + || join(home, ".hermes", "orchestrator-state.json"); +const logPath = process.env.IRIS_HEARTBEAT_LOG + || join(home, ".hermes", "logs", "heartbeat.log"); +const lockPath = process.env.IRIS_RECOVERY_LOCK + || join(home, ".hermes", "iris-heartbeat-recovery.lock"); +const captutor = process.env.CAPTUTOR_HOME || join(home, "Developer", "captutor"); +const node = process.env.CAPTUTOR_NODE || "/opt/homebrew/bin/node"; +const maxRetries = Number(process.env.IRIS_CAPTUTOR_RECOVERY_RETRIES || 1); +const reloadWaitMs = Number(process.env.IRIS_BROWSER_RELOAD_WAIT_MS || 5_000); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function readJson(path) { + try { return JSON.parse(readFileSync(path, "utf8")); } + catch { return null; } +} + +function atomicWriteJson(path, value) { + mkdirSync(dirname(path), { recursive:true }); + const temp = `${path}.${process.pid}.tmp`; + writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode:0o600 }); + renameSync(temp, path); +} + +function log(message) { + mkdirSync(dirname(logPath), { recursive:true }); + writeFileSync(logPath, `${new Date().toISOString()} ♥ recovery · ${message}\n`, { flag:"a" }); +} + +function processAlive(pid) { + if (!pid) return false; + try { process.kill(Number(pid), 0); return true; } + catch { return false; } +} + +export function latestRecoverableFailure(state) { + if (state?.active) return null; + return Object.entries(state?.done || {}) + .filter(([, record]) => record?.kind === "captutor" && record.status === "failed") + .map(([taskGid, record]) => ({ taskGid, ...record })) + .sort((a, b) => Number(b.at || 0) - Number(a.at || 0))[0] || null; +} + +export function recoveryAttempts(state, taskGid) { + return Number(state?.recoveries?.[taskGid]?.attempts || 0); +} + +export function markStoppedWorkerFailed(state, now = Date.now()) { + const active = state?.active; + if (!active || active.kind !== "captutor" || processAlive(active.pid)) return null; + if (now - Number(active.startedAt || now) < 2 * 60_000) return null; + state.done ||= {}; + state.done[active.taskGid] = { + name:active.name, + kind:"captutor", + status:"failed", + reason:"worker-disappeared", + detail:`worker ${active.pid || "?"} stopped before verified outbox delivery`, + log:active.log, + at:now, + }; + state.active = null; + return state.done[active.taskGid]; +} + +export function beginRecovery(state, failure, now = Date.now(), maximum = maxRetries) { + state.recoveries ||= {}; + const previous = state.recoveries[failure.taskGid] || {}; + const attempts = Number(previous.attempts || 0); + if (attempts >= maximum) { + state.recovery = { + taskGid:failure.taskGid, mission:failure.name, status:"exhausted", + attempts, maximum, reason:failure.reason || "missing-outbox-artifacts", + detail:failure.detail || "No verified Captutor outbox delivery.", updatedAt:now, + }; + return false; + } + state.recovery = { + taskGid:failure.taskGid, mission:failure.name, status:"checking-browser", + attempts, maximum, reason:failure.reason || "missing-outbox-artifacts", + detail:failure.detail || "No verified Captutor outbox delivery.", updatedAt:now, + }; + return true; +} + +export function queueRecovery(state, failure, now = Date.now()) { + state.recoveries ||= {}; + const attempts = recoveryAttempts(state, failure.taskGid) + 1; + state.recoveries[failure.taskGid] = { + attempts, lastAttemptAt:now, reason:failure.reason || "missing-outbox-artifacts", + }; + state.recovery = { + ...state.recovery, + taskGid:failure.taskGid, + mission:failure.name, + status:"queued", + attempts, + updatedAt:now, + activity:`Browser healthy; retry ${attempts}/${state.recovery?.maximum || maxRetries} queued.`, + }; + delete state.done[failure.taskGid]; + return state.recovery; +} + +function browserProbe() { + try { + execFileSync(node, [join(captutor, "bin", "cdp-frame.mjs"), + "--match", "fuser.studio", "--compact", "--timeout", "10000"], { + cwd:captutor, + env:{ ...process.env, CDP_PORT:process.env.CDP_PORT || "9333", CAPTUTOR_CDP_EPHEMERAL:"1" }, + encoding:"utf8", timeout:12_000, stdio:["ignore", "pipe", "pipe"], + }); + return { healthy:true, crash:false, message:"Fuser renderer is responsive." }; + } catch (error) { + const output = `${error.stdout || ""}\n${error.stderr || ""}`; + return { + healthy:false, + crash:/BROWSER_RENDERER_CRASH|Inspector\.targetCrashed/.test(output), + message:output.trim().slice(-500) || error.message, + }; + } +} + +async function recoverBrowser(state) { + let health = browserProbe(); + if (health.healthy) return health; + if (!health.crash) return health; + state.recovery.status = "reloading-browser"; + state.recovery.activity = "Renderer crashed; reloading the Fuser tab."; + state.recovery.updatedAt = Date.now(); + atomicWriteJson(statePath, state); + execFileSync("/usr/bin/osascript", ["-e", + 'tell application "Google Chrome" to reload active tab of front window'], { + timeout:10_000, stdio:"ignore", + }); + await delay(reloadWaitMs); + health = browserProbe(); + return health; +} + +export async function runRecovery() { + let locked = false; + try { + mkdirSync(lockPath); + locked = true; + } catch { + return { action:"busy" }; + } + try { + const state = readJson(statePath); + if (!state) return { action:"no-state" }; + const stopped = markStoppedWorkerFailed(state); + if (stopped) atomicWriteJson(statePath, state); + const failure = latestRecoverableFailure(state); + if (!failure) return { action:"none" }; + if (!beginRecovery(state, failure)) { + atomicWriteJson(statePath, state); + log(`${failure.name}: automatic retry exhausted`); + return { action:"exhausted", failure }; + } + atomicWriteJson(statePath, state); + const health = await recoverBrowser(state); + if (!health.healthy) { + state.recovery.status = "blocked"; + state.recovery.activity = `Browser recovery failed: ${health.message}`; + state.recovery.updatedAt = Date.now(); + atomicWriteJson(statePath, state); + log(`${failure.name}: browser recovery blocked`); + return { action:"blocked", failure, health }; + } + const recovery = queueRecovery(state, failure); + atomicWriteJson(statePath, state); + log(`${failure.name}: ${recovery.activity}`); + return { action:"queued", failure, health, recovery }; + } finally { + if (locked && existsSync(lockPath)) rmSync(lockPath, { recursive:true, force:true }); + } +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const result = await runRecovery(); + console.log(JSON.stringify(result)); +} diff --git a/captutor/ops/iris-orchestrator.mjs b/captutor/ops/iris-orchestrator.mjs new file mode 100644 index 000000000..d216e1d27 --- /dev/null +++ b/captutor/ops/iris-orchestrator.mjs @@ -0,0 +1,587 @@ +#!/usr/bin/env node +// iris-orchestrator — makes iris self-driving, safely. Polls her ASSIGNED Asana +// tasks and works them ONE AT A TIME. Code tasks spawn a worker in an isolated +// worktree and end in a PR. Tasks tagged BOTH `mission` and `captutor` run in the +// fixed Captutor workspace and end in a verified Desktop/outbox artifact. +// The orchestrator keeps her mission board as the live work-list +// (in-progress + queued), and posts status to Slack. Extra assignments queue and +// wait. Gated by the ownership guardrail: only ASSIGNED tasks are worked, and if +// the active task is unassigned mid-flight she stands down. +// +// This is the ORCHESTRATION layer — the queue/lock/status/mission. Whether an +// individual worker nails a given task is the worker's job; this guarantees only +// one runs, the human is kept informed, and nothing unassigned is touched. +// +// Dependency-free: node https + child_process (flk, gh). Persistent via launchd. + +import { execFileSync, spawn } from "node:child_process"; +import https from "node:https"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const H = homedir(); +const DIR = join(H, ".hermes"); +const STATE = join(DIR, "orchestrator-state.json"); +const MISSION_FILE = join(H, ".local", "share", "desktop-badge", "mission.json"); +const MANUAL_MISSION_FILE = join(H, ".hermes", "manual-mission.json"); +const LOG = join(DIR, "logs", "orchestrator.log"); + +const ASANA = process.env.ASANA_ACCESS_TOKEN || ""; +const SLACK = process.env.SLACK_BOT_TOKEN || ""; +const USER_GID = process.env.ASANA_USER_GID || "1216250551404992"; +const WS_GID = process.env.ASANA_WORKSPACE_GID || "1208084256731239"; +const HOME_CHANNEL = process.env.ORCH_CHANNEL || "D0BFUT0D4SF"; // iris<->jeffrey DM +const FUSER = process.env.FUSER_REPO || join(H, "Developer", "fuser"); +const CAPTUTOR = process.env.CAPTUTOR_HOME || join(H, "Developer", "captutor"); +const OUTBOX = process.env.CAPTUTOR_OUTBOX || join(H, "Desktop", "outbox"); +const DESK_CLEANUP = process.env.IRIS_DESK_CLEANUP || join(H, ".local", "bin", "iris-desk-cleanup"); +const WORKER_RUN = join(DIR, "bin", "worker-run.sh"); +const WORKTREES = join(DIR, "worktrees"); +const GH_ENV = { ...process.env, GH_CONFIG_DIR: join(H, ".config", "gh-iris") }; +const BASE_REF = process.env.ORCH_BASE_REF || "origin/staging"; +const POLL_MS = parseInt(process.env.ORCH_POLL_MS || "60000", 10); +const STALL_MS = parseInt(process.env.ORCH_STALL_MS || String(20 * 60000), 10); +// Routine "starting" / "still working" chatter is intentionally quiet by +// default. Completion, review, stand-down, and failure notices still go to +// Slack. Set ORCH_SLACK_PROGRESS=true when live progress narration is useful. +const SLACK_PROGRESS = process.env.ORCH_SLACK_PROGRESS === "true"; +// Review loop: iris watches her own open PRs for teammate feedback (reviews live +// on GitHub, not Asana — the orchestrator never saw them). Ingestion + Slack +// notify is ALWAYS on. Auto-addressing (re-launch a worker on the PR branch to +// fix the feedback) is gated behind this flag so we verify ingestion first. +const REVIEW_AUTOFIX = process.env.ORCH_REVIEW_AUTOFIX === "true"; +const REPO = process.env.FUSER_GH_REPO || "fuserstudio/fuser"; +const IRIS_LOGIN = process.env.IRIS_GH_LOGIN || "iris-fuser"; + +function log(m) { + const line = new Date().toISOString() + " " + m + "\n"; + try { if (!existsSync(join(DIR, "logs"))) mkdirSync(join(DIR, "logs"), { recursive: true }); } catch {} + try { writeFileSync(LOG, line, { flag: "a" }); } catch {} + process.stdout.write(line); +} + +function httpsJson({ hostname, path, method = "GET", headers = {}, body = null }) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const req = https.request( + { hostname, path, method, headers: { ...headers, ...(data ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } : {}) }, timeout: 15000 }, + (res) => { let b = ""; res.on("data", (c) => (b += c)); res.on("end", () => { try { resolve(JSON.parse(b)); } catch (e) { reject(e); } }); }, + ); + req.on("error", reject); + req.on("timeout", () => req.destroy(new Error("timeout"))); + if (data) req.write(data); + req.end(); + }); +} + +async function assignedTasks() { + const fields = "name,notes,completed,permalink_url,tags.name"; + const j = await httpsJson({ + hostname: "app.asana.com", + path: `/api/1.0/tasks?assignee=${USER_GID}&workspace=${WS_GID}&completed_since=now&opt_fields=${fields}&limit=100`, + headers: { Authorization: "Bearer " + ASANA }, + }); + if (j.errors) throw new Error(j.errors[0]?.message || "asana error"); + return (j.data || []).filter((t) => !t.completed).map((t) => { + const tags = (t.tags || []).map((tag) => String(tag.name || "").toLowerCase()); + return { + gid: t.gid, + name: t.name || "(untitled)", + notes: t.notes || "", + url: t.permalink_url || null, + tags, + kind: tags.includes("mission") && tags.includes("captutor") ? "captutor" : "pr", + }; + }); +} + +async function asanaComment(gid, text) { + try { + const r = await httpsJson({ + hostname: "app.asana.com", path: `/api/1.0/tasks/${gid}/stories`, method: "POST", + headers: { Authorization: "Bearer " + ASANA }, body: { data: { text } }, + }); + if (r.errors) log("asana comment failed: " + (r.errors[0]?.message || "?")); + } catch (e) { log("asana comment err: " + e.message); } +} + +async function asanaComplete(gid) { + try { + const r = await httpsJson({ + hostname: "app.asana.com", path: `/api/1.0/tasks/${gid}`, method: "PUT", + headers: { Authorization: "Bearer " + ASANA }, body: { data: { completed: true } }, + }); + if (r.errors) log("asana completion failed: " + (r.errors[0]?.message || "?")); + } catch (e) { log("asana completion err: " + e.message); } +} + +async function slack(text) { + if (!SLACK) { log("no SLACK_BOT_TOKEN, skip: " + text); return; } + try { + const r = await httpsJson({ hostname: "slack.com", path: "/api/chat.postMessage", method: "POST", + headers: { Authorization: "Bearer " + SLACK }, body: { channel: HOME_CHANNEL, text, unfurl_links: false } }); + if (!r.ok) log("slack failed: " + (r.error || "?")); + } catch (e) { log("slack err: " + e.message); } +} + +async function progressSlack(text) { + if (!SLACK_PROGRESS) { log("slack progress suppressed: " + text); return; } + await slack(text); +} + +function git(args, opts = {}) { + return execFileSync("git", args, { cwd: FUSER, encoding: "utf8", timeout: 60000, ...opts }).trim(); +} +function slug(s) { return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24) || "task"; } + +// Create a fresh isolated worktree off the latest base tip (idempotent). +function makeWorktree(wt, branch) { + try { git(["worktree", "remove", "--force", wt]); } catch {} + try { git(["branch", "-D", branch]); } catch {} + git(["fetch", "origin", "staging", "--quiet"]); + git(["worktree", "add", "-b", branch, wt, BASE_REF]); +} +function removeWorktree(wt, branch) { + try { git(["worktree", "remove", "--force", wt]); } catch {} + try { git(["branch", "-D", branch]); } catch {} +} + +// Launch the headless worker DETACHED (the daemon owns it, not this tick). +function launchWorker(wt, log, prompt, promptFile, extraEnv = {}) { + writeFileSync(promptFile, prompt); + const child = spawn("bash", [WORKER_RUN, wt, log, promptFile], { + cwd: wt, detached: true, stdio: "ignore", env: { ...process.env, ...extraEnv }, + }); + child.unref(); + return child.pid; +} +// The headless run appended its exit marker → the worker has finished. +function workerFinished(log) { + try { return /=== worker-run exit /.test(readFileSync(log, "utf8")); } catch { return false; } +} + +function workerExit(log) { + try { + const matches = [...readFileSync(log, "utf8").matchAll(/=== worker-run exit (\d+) /g)]; + return matches.length ? Number(matches.at(-1)[1]) : null; + } catch { return null; } +} + +function captutorDeliveries(taskGid, startedAt = 0) { + const found = []; + try { + for (const name of readdirSync(OUTBOX).filter((f) => f.endsWith(".json")).sort().reverse()) { + const path = join(OUTBOX, name); + let doc; + try { doc = JSON.parse(readFileSync(path, "utf8")); } catch { continue; } + if (doc.schema !== "captutor-outbox/v1" || doc.status !== "complete" || doc.taskGid !== taskGid) continue; + if (startedAt && Date.parse(doc.createdAt || 0) < startedAt) continue; + const video = join(OUTBOX, doc.video || ""); + const captions = join(OUTBOX, doc.captions || ""); + if (existsSync(video) && existsSync(captions)) { + found.push({ manifest: path, video, captions, metadata: doc }); + } + } + } catch {} + return found; +} + +function captutorExpectedFormats(notes) { + const hit = String(notes || "").match(/^CAPTUTOR_FORMATS:\s*([^\n]+)$/mi); + return hit ? hit[1].split(",").map((value) => value.trim()).filter(Boolean) : []; +} + +function processAlive(pid) { + if (!pid) return false; + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function releaseActiveWorkspace(active) { + if (active?.wt && active?.branch) removeWorktree(active.wt, active.branch); +} + +function stopActiveWorker(active) { + if (!active?.pid) return; + try { process.kill(-active.pid, "SIGTERM"); } catch {} +} + +function prForBranch(branch) { + try { + const out = execFileSync("gh", ["pr", "list", "--repo", "fuserstudio/fuser", "--head", branch, "--json", "url,state", "--limit", "1"], + { cwd: FUSER, encoding: "utf8", timeout: 20000, env: GH_ENV }).trim(); + const arr = JSON.parse(out || "[]"); + return arr[0] || null; + } catch { return null; } +} + +// Worktree checked out on an EXISTING PR branch (for addressing review feedback, +// vs makeWorktree which cuts a fresh branch off BASE_REF). +function makeWorktreeOnBranch(wt, branch) { + try { git(["worktree", "remove", "--force", wt]); } catch {} + git(["fetch", "origin", branch, "--quiet"]); + git(["worktree", "add", "--force", "-B", branch, wt, `origin/${branch}`]); +} + +// ── GitHub review ingestion (read-only; runs gh under iris's gh-iris config) ── +function gh(args) { + try { return execFileSync("gh", args, { cwd: FUSER, encoding: "utf8", timeout: 30000, env: GH_ENV }).trim(); } + catch (e) { log("gh err [" + args.join(" ") + "]: " + (e.stderr || e.message)); return null; } +} +function ghApi(path) { + const out = gh(["api", "-H", "Accept: application/vnd.github+json", path + (path.includes("?") ? "&" : "?") + "per_page=100"]); + try { return out ? JSON.parse(out) : []; } catch { return []; } +} +function irisOpenPRs() { + const out = gh(["pr", "list", "--repo", REPO, "--author", IRIS_LOGIN, "--state", "open", + "--json", "number,url,title,headRefName,reviewDecision", "--limit", "50"]); + try { return out ? JSON.parse(out) : []; } catch { return []; } +} +// New teammate feedback on a PR since the recorded high-water marks. Ignores +// iris's own comments. Returns { items:[...], hwm:{comment,review,issue} }. +function prFeedback(number, hwm) { + const items = []; + const nhwm = { comment: hwm.comment || 0, review: hwm.review || 0, issue: hwm.issue || 0 }; + for (const c of ghApi(`repos/${REPO}/pulls/${number}/comments`)) { + if (c.user?.login === IRIS_LOGIN) continue; + if (c.id > (hwm.comment || 0)) items.push({ kind: "comment", id: c.id, author: c.user?.login || "?", body: c.body || "", loc: `${c.path}:${c.line ?? c.original_line ?? "?"}` }); + nhwm.comment = Math.max(nhwm.comment, c.id); + } + for (const r of ghApi(`repos/${REPO}/pulls/${number}/reviews`)) { + if (r.user?.login === IRIS_LOGIN || !r.state || r.state === "PENDING") continue; + if (r.id > (hwm.review || 0)) items.push({ kind: "review", id: r.id, author: r.user?.login || "?", state: r.state, body: r.body || `(${r.state})` }); + nhwm.review = Math.max(nhwm.review, r.id); + } + for (const c of ghApi(`repos/${REPO}/issues/${number}/comments`)) { + if (c.user?.login === IRIS_LOGIN) continue; + if (c.id > (hwm.issue || 0)) items.push({ kind: "issue", id: c.id, author: c.user?.login || "?", body: c.body || "" }); + nhwm.issue = Math.max(nhwm.issue, c.id); + } + return { items, hwm: nhwm }; +} + +function loadState() { + try { + const s = JSON.parse(readFileSync(STATE, "utf8")); + if (!s.reviews) s.reviews = {}; + if (!s.recoveries) s.recoveries = {}; + return s; + } catch { return { active: null, done: {}, reviews: {}, recoveries: {} }; } +} +function saveState(s) { writeFileSync(STATE, JSON.stringify(s, null, 2) + "\n"); } + +function writeMission(active, tasks, done) { + let manual = null; + try { manual = JSON.parse(readFileSync(MANUAL_MISSION_FILE, "utf8")); } catch {} + // Only heartbeat.sh advances lastHeartbeat. Orchestrator ticks still refresh + // updatedAt/content, but must not impersonate a beat or the five-minute + // countdown will reset every minute and MacPal will animate false pulses. + let previousHeartbeat = null; + try { previousHeartbeat = JSON.parse(readFileSync(MISSION_FILE, "utf8")).lastHeartbeat || null; } catch {} + const now = new Date().toISOString(); + const lastHeartbeat = previousHeartbeat || now; + const items = []; + if (active) items.push({ text: `${active.name}`, status: "in_progress" }); + for (const t of tasks) if (!done[t.gid] && (!active || t.gid !== active.taskGid)) items.push({ text: t.name, status: "pending" }); + // Completed work stays in orchestrator state for audit/deduplication, but an + // idle mission should actually look clear instead of carrying old trophies. + const remaining = tasks.filter((t) => !done[t.gid]); + const recent = active || tasks.length ? Object.values(done).slice(-2) : []; + for (const d of recent) items.push({ + text: `${d.name} (${d.status === "failed" ? "needs attention" : d.kind === "captutor" ? "rendered" : "shipped"})`, + status: d.status === "failed" ? "pending" : "done", + }); + const useManual = !active && tasks.length === 0 && manual?.mission; + const doc = useManual ? { + ...manual, + agent: "iris", + updatedAt:now, + lastHeartbeat, + heartbeatIntervalSeconds:300, + } : { + mission: active?.kind === "captutor" ? "rendering a product demo" + : active ? "working PRs (one at a time)" + : recent.some((d) => d.status === "failed") ? "mission needs attention" + : remaining.some((t) => t.kind === "captutor") ? "product demos queued" + : remaining.length ? "queued PRs" : "idle — no assigned tasks", + emoji:"🪽", agent:"iris", updatedAt:now, + items:items.slice(0, 8), lastHeartbeat, heartbeatIntervalSeconds:300, + }; + try { if (!existsSync(join(H, ".local", "share", "desktop-badge"))) mkdirSync(join(H, ".local", "share", "desktop-badge"), { recursive: true }); } catch {} + writeFileSync(MISSION_FILE, JSON.stringify(doc, null, 2) + "\n"); +} + +// Scan iris's open PRs for new teammate feedback. Always notifies (Slack). If +// REVIEW_AUTOFIX and changes were requested AND iris is still the Asana assignee +// of the PR's task, claim the active slot and launch a worker on a worktree of +// the EXISTING branch to address it. Returns true if it claimed the slot. +async function reviewPass(s, assigned) { + for (const pr of irisOpenPRs()) { + const rec = s.reviews[pr.number] || (s.reviews[pr.number] = { branch: pr.headRefName, hwm: {}, seeded: false }); + rec.branch = pr.headRefName; + const { items, hwm } = prFeedback(pr.number, rec.hwm); + rec.hwm = hwm; // advance always so we never re-alert on the same comments + + // First sight of a PR: seed high-water marks silently — no backlog alerts. + if (!rec.seeded) { rec.seeded = true; saveState(s); continue; } + if (!items.length) { saveState(s); continue; } + + const changesRequested = pr.reviewDecision === "CHANGES_REQUESTED" || items.some((i) => i.state === "CHANGES_REQUESTED"); + const line = (i) => `• ${i.author}${i.loc ? ` (${i.loc})` : ""}: ${(i.body || "").split("\n")[0].slice(0, 140)}`; + await slack(`💬 new review on *${pr.title}* (${pr.url})${changesRequested ? " — changes requested" : ""}:\n${items.slice(0, 4).map(line).join("\n")}`); + saveState(s); + + if (!(REVIEW_AUTOFIX && changesRequested) || s.active) continue; + + // Ownership hard gate: only auto-address if iris is the CURRENT assignee of + // this PR's Asana task (mapped via what we recorded when we opened it). + const taskGid = Object.keys(s.done).find((g) => (s.done[g].pr || "").includes(`/pull/${pr.number}`)) || null; + if (!taskGid || !assigned.has(taskGid)) { + await slack(`↳ not auto-addressing #${pr.number} — I'm not the current assignee. flagging for a human.`); + continue; + } + const name = `review-${pr.number}`; + const wt = join(WORKTREES, name); + const logf = join(DIR, "logs", `worker-${name}.log`); + const promptFile = join(DIR, `worker-${name}.prompt`); + const body = items.map((i) => `- ${i.author}${i.loc ? ` [${i.loc}]` : ""}: ${i.body}`).join("\n"); + const prompt = + `A teammate left review feedback on your PR #${pr.number} (${pr.url}). Address it end to end.\n\n` + + `You are in a fresh git worktree already checked out on the EXISTING PR branch ${pr.headRefName} (reset to origin/${pr.headRefName}). Do NOT start a new branch. Rebuild context first: git log, gh pr view ${pr.number} --repo ${REPO}.\n\n` + + `Review feedback:\n${body}\n\n` + + `Make the changes, commit only the relevant files, then update the PR:\n` + + ` git push --force-with-lease origin ${pr.headRefName}\n` + + `Then reply on the PR summarizing what changed:\n` + + ` gh pr comment ${pr.number} --repo ${REPO} --body ""\n` + + `If a comment is unclear or you disagree, ask on the PR instead of guessing. Print the PR URL when done.`; + try { + makeWorktreeOnBranch(wt, pr.headRefName); + s.active = { taskGid, name: `review: ${pr.title}`, branch: pr.headRefName, wt, log: logf, startedAt: Date.now(), kind: "review", prNumber: pr.number }; + saveState(s); + try { writeFileSync(logf, ""); } catch {} + launchWorker(wt, logf, prompt, promptFile); + await progressSlack(`🛠️ addressing the review on *${pr.title}* — on it now.`); + log(`launched review worker for PR #${pr.number} (branch ${pr.headRefName})`); + return true; + } catch (e) { log("review spawn err: " + e.message); } + } + return false; +} + +async function tick() { + if (!ASANA) { log("no ASANA token"); return; } + let tasks; + try { tasks = await assignedTasks(); } catch (e) { log("asana poll err: " + e.message); return; } + const assigned = new Set(tasks.map((t) => t.gid)); + const s = loadState(); + + // Guardrail: active task unassigned → stand down. (Review-fix items carry the + // taskGid they were gated on; a plain item with no taskGid is skipped here.) + if (s.active && s.active.taskGid && !assigned.has(s.active.taskGid)) { + log(`active task ${s.active.taskGid} unassigned → standing down`); + stopActiveWorker(s.active); + releaseActiveWorkspace(s.active); + await slack(`🛑 stood down on *${s.active.name}* — it's no longer assigned to me.`); + s.active = null; saveState(s); + } + + // Progress the active work. For a review fix the PR already exists, so + // completion is the worker finishing (not a PR appearing). + if (s.active) { + if (s.active.kind === "captutor") { + const exit = workerExit(s.active.log); + if (exit !== null) { + const deliveries = exit === 0 + ? captutorDeliveries(s.active.taskGid, s.active.startedAt) + : []; + const formats = new Set(deliveries.map((delivery) => delivery.metadata.format)); + const missing = (s.active.expectedFormats || []).filter((format) => !formats.has(format)); + if (deliveries.length && !missing.length) { + const artifactLines = deliveries + .sort((a, b) => String(a.metadata.format).localeCompare(String(b.metadata.format))) + .flatMap((delivery) => [ + `${delivery.metadata.format}: ${delivery.video}`, + `manifest: ${delivery.manifest}`, + ]); + log(`captutor mission ${s.active.name} → ${deliveries.length} verified artifact(s)`); + await asanaComment(s.active.taskGid, + `iris rendered this product-demo mission and placed the verified artifacts in Panda's Desktop outbox:\n${artifactLines.join("\n")}`); + await asanaComplete(s.active.taskGid); + await slack(`🎬 rendered *${s.active.name}* → ${deliveries.map((delivery) => delivery.video).join(", ")}`); + s.done[s.active.taskGid] = { + name: s.active.name, kind: "captutor", status: "done", + deliveries, at: Date.now(), + }; + if (s.recovery?.taskGid === s.active.taskGid) { + s.recovery = { + ...s.recovery, status:"complete", updatedAt:Date.now(), + activity:`Recovered mission completed with ${deliveries.length} verified artifact(s).`, + }; + } + } else { + const detail = missing.length ? `; missing formats: ${missing.join(", ")}` : ""; + const reason = exit === 0 ? "missing-outbox-artifacts" : "worker-exit"; + const failureDetail = exit === 0 + ? `worker exited 0 but produced ${deliveries.length} verified artifact(s)${detail}` + : `worker exited ${exit} before verified delivery${detail}`; + log(`captutor mission ${s.active.name} exited ${exit} without every verified outbox artifact${detail}`); + await asanaComment(s.active.taskGid, + `iris could not complete this Captutor mission (worker exit ${exit}; ${deliveries.length} verified artifact(s)${detail}). Log: ${s.active.log}`); + await slack(`⚠️ Captutor mission *${s.active.name}* needs attention — not every requested video reached the outbox.`); + s.done[s.active.taskGid] = { + name:s.active.name, kind:"captutor", status:"failed", + reason, detail:failureDetail, exitCode:exit, + verifiedArtifacts:deliveries.length, missingFormats:missing, + log:s.active.log, at:Date.now(), + }; + if (s.recovery?.taskGid === s.active.taskGid) { + s.recovery = { + ...s.recovery, status:"failed", reason, detail:failureDetail, + updatedAt:Date.now(), activity:`Retry stopped: ${failureDetail}.`, + }; + } + } + s.active = null; saveState(s); + } else if (Date.now() - s.active.startedAt > 2 * 60000 && !processAlive(s.active.pid)) { + log(`captutor mission ${s.active.name} lost worker ${s.active.pid || "?"} without an exit marker`); + await asanaComment(s.active.taskGid, + `iris's Captutor worker disappeared before writing an exit marker. The task remains incomplete for retry. Log: ${s.active.log}`); + s.done[s.active.taskGid] = { + name:s.active.name, kind:"captutor", status:"failed", + reason:"worker-disappeared", + detail:`worker ${s.active.pid || "?"} stopped before writing an exit marker`, + log:s.active.log, at:Date.now(), + }; + if (s.recovery?.taskGid === s.active.taskGid) { + s.recovery = { + ...s.recovery, status:"failed", reason:"worker-disappeared", + detail:`worker ${s.active.pid || "?"} stopped before writing an exit marker`, + updatedAt:Date.now(), activity:"Retry worker disappeared before verified delivery.", + }; + } + s.active = null; saveState(s); + } else if (Date.now() - s.active.startedAt > STALL_MS && !s.active.stallNoted) { + await progressSlack(`⏳ still rendering *${s.active.name}* (${Math.round((Date.now() - s.active.startedAt) / 60000)}m).`); + s.active.stallNoted = true; saveState(s); + } + } else if (s.active.kind === "review") { + const label = s.active.name.replace(/^review: /, ""); + if (workerFinished(s.active.log)) { + log(`review worker for #${s.active.prNumber} finished`); + await slack(`✅ pushed updates for the review on *${label}*${s.active.prNumber ? ` (#${s.active.prNumber})` : ""}.`); + if (s.active.prNumber && s.reviews[s.active.prNumber]) s.reviews[s.active.prNumber].addressedAt = Date.now(); + removeWorktree(s.active.wt, s.active.branch); + s.active = null; saveState(s); + } else if (Date.now() - s.active.startedAt > STALL_MS && !s.active.stallNoted) { + await progressSlack(`⏳ still addressing the review on *${label}* (${Math.round((Date.now() - s.active.startedAt) / 60000)}m).`); + s.active.stallNoted = true; saveState(s); + } + } else { + const pr = prForBranch(s.active.branch); + if (pr) { + log(`active ${s.active.name} → PR ${pr.url}`); + await asanaComment(s.active.taskGid, `iris opened a PR for this task: ${pr.url} — ready for review.`); + await slack(`✅ opened a PR for *${s.active.name}*: ${pr.url}`); + s.done[s.active.taskGid] = { name: s.active.name, branch: s.active.branch, pr: pr.url, at: Date.now() }; + removeWorktree(s.active.wt, s.active.branch); + s.active = null; saveState(s); + } else if (workerFinished(s.active.log)) { + log(`worker for ${s.active.name} finished without a PR → releasing`); + await slack(`⚠️ my worker on *${s.active.name}* finished without opening a PR — I'll need a retry (log: ${s.active.log}).`); + removeWorktree(s.active.wt, s.active.branch); + s.active = null; saveState(s); + } else if (Date.now() - s.active.startedAt > STALL_MS) { + if (!s.active.stallNoted) { await progressSlack(`⏳ still working *${s.active.name}* (${Math.round((Date.now() - s.active.startedAt) / 60000)}m) — flagging in case it's stuck.`); s.active.stallNoted = true; saveState(s); } + } + } + } + + // Watch iris's open PRs for teammate feedback and (gated) address it before + // starting new work — a requested change on an in-flight PR outranks a fresh + // task. Notify-only until ORCH_REVIEW_AUTOFIX is on. + if (!s.active) { try { await reviewPass(s, assigned); } catch (e) { log("reviewPass err: " + e.message); } } + + // Pick up the next assigned task (one at a time). + if (!s.active) { + const next = tasks.find((t) => !s.done[t.gid]); + if (next) { + const name = `${slug(next.name)}-${next.gid.slice(-6)}`; + const logf = join(DIR, "logs", `worker-${name}.log`); + const promptFile = join(DIR, `worker-${name}.prompt`); + if (next.kind === "captutor") { + const expectedFormats = captutorExpectedFormats(next.notes); + const prompt = + `Execute this assigned Captutor product-demo mission end to end on Panda. DO NOT edit the Fuser repo, create a branch, commit, push, or open a PR.\n\n` + + `Task: ${next.name}\n${next.notes ? next.notes + "\n" : ""}${next.url ? "Asana: " + next.url + "\n" : ""}\n` + + `You are already in the current Captutor workspace. Read README.md first. Verify the recorder, GUI Chrome/CDP session, login, and credits as applicable. ` + + `For UI pathfinding, use the bounded internal frame (\`CDP_PORT=9333 node bin/cdp-frame.mjs --match fuser.studio\`, optionally with \`--screenshot /tmp/preflight.png\`). ` + + `It returns controls plus React Flow nodes/handles/edges without visible tooling and closes CDP cleanly; do not write ad-hoc attach scripts or repeatedly map unrelated UI. ` + + `Keep the filmed interaction on Fuser's canvas. Treat the right-side node properties inspector as off-camera setup only: close it before Reel and do not open it during a take unless the task explicitly teaches that inspector. ` + + `Before Stage, verify System Events Accessibility and the SlabMenubar recording bridge. If macOS presents an in-scope System Settings, Accessibility, Automation, or Screen Recording permission prompt, approve it off camera and verify the grant before continuing; never film a permission dialog. ` + + `Run Captutor in the FOREGROUND. Invoke the Stage render as a direct foreground child of this worker with a long enough shell timeout. Do not use Monitor, a subagent, a background job, nohup, or a scheduled check-in for the render; remain attached until the command exits and the required outbox files have been verified. ` + + `Pathfind on the ordinary desktop, but perform every actual take in Captutor's true 2x HiDPI Stage. Render with \`node bin/stage.mjs render --outbox "$CAPTUTOR_OUTBOX"\`; never invoke \`captutor.mjs render\` directly for a mission. ` + + `The environment already sets CAPTUTOR_TASK_GID and CAPTUTOR_REQUIRE_HIDPI for this task, and Captutor will refuse to start Reel unless Stage and its real 2x display geometry are active. ` + + `Success means Captutor writes a complete captutor-outbox/v1 manifest plus its MP4 and VTT for every requested format to the outbox. ` + + `Do not substitute an old render. If blocked, explain the exact blocker and exit nonzero.`; + try { + if (!existsSync(CAPTUTOR)) throw new Error(`missing Captutor workspace: ${CAPTUTOR}`); + if (!existsSync(DESK_CLEANUP)) throw new Error(`missing pre-mission setup: ${DESK_CLEANUP}`); + log(`running pre-mission setup: ${DESK_CLEANUP}`); + execFileSync(DESK_CLEANUP, [], { encoding: "utf8", timeout: 120000 }); + mkdirSync(OUTBOX, { recursive: true }); + s.active = { + taskGid: next.gid, name: next.name, kind: "captutor", cwd: CAPTUTOR, + log: logf, startedAt: Date.now(), expectedFormats, + recoveryAttempt:s.recoveries?.[next.gid]?.attempts || 0, + }; + if (s.recovery?.taskGid === next.gid) { + s.recovery = { + ...s.recovery, status:"relaunching", updatedAt:Date.now(), + activity:`Retry ${s.recovery.attempts}/${s.recovery.maximum} is starting in the foreground.`, + }; + } + saveState(s); + try { writeFileSync(logf, ""); } catch {} + s.active.pid = launchWorker(CAPTUTOR, logf, prompt, promptFile, { + CAPTUTOR_TASK_GID: next.gid, + CAPTUTOR_OUTBOX: OUTBOX, + CAPTUTOR_REQUIRE_HIDPI: "1", + }); + saveState(s); + log(`launched Captutor mission for ${next.name}`); + await progressSlack(`🎬 starting product-demo mission *${next.name}*${next.url ? " (" + next.url + ")" : ""}.`); + } catch (e) { log("captutor mission spawn err: " + e.message); } + writeMission(s.active, tasks, s.done); + return; + } + + const branch = `iris/${name}`; + const wt = join(WORKTREES, name); + const prompt = + `Do this assigned task end to end, then OPEN A PULL REQUEST.\n\n` + + `Task: ${next.name}\n${next.notes ? next.notes + "\n" : ""}${next.url ? "Asana: " + next.url + "\n" : ""}\n` + + `You are in a fresh git worktree on branch ${branch} off ${BASE_REF}. Make the change, ` + + `commit only the relevant files, push the branch to origin, then open a PR:\n` + + ` gh pr create --repo fuserstudio/fuser --base staging --head ${branch} --title "" --body ""\n` + + `Print the PR URL when done.`; + try { + makeWorktree(wt, branch); + // Claim + persist the lock BEFORE launching / any async, so a crash or + // re-tick can never re-pick this task or drop the one-at-a-time lock. + s.active = { taskGid: next.gid, name: next.name, branch, wt, log: logf, startedAt: Date.now() }; + saveState(s); + try { writeFileSync(logf, ""); } catch {} + launchWorker(wt, logf, prompt, promptFile); + log(`launched worker for ${next.name} (branch ${branch})`); + const queued = tasks.filter((t) => !s.done[t.gid] && t.gid !== next.gid).length; + await progressSlack(`🛠️ starting on *${next.name}*${next.url ? " (" + next.url + ")" : ""} — one PR at a time${queued ? `; ${queued} more queued` : ""}.`); + } catch (e) { log("spawn err: " + e.message); } + } + } + + writeMission(s.active, tasks, s.done); +} + +log(`iris-orchestrator starting — poll ${POLL_MS}ms, base ${BASE_REF}, review ${REVIEW_AUTOFIX ? "auto-fix" : "notify-only"}, slack progress ${SLACK_PROGRESS ? "on" : "off"}`); +await tick(); +setInterval(() => tick().catch((e) => log("tick crash: " + e.message)), POLL_MS); diff --git a/captutor/ops/iris-progress-publisher.mjs b/captutor/ops/iris-progress-publisher.mjs new file mode 100644 index 000000000..22a73ce34 --- /dev/null +++ b/captutor/ops/iris-progress-publisher.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; + +const home = homedir(); +const statePath = join(home, ".hermes", "orchestrator-state.json"); +const outputDir = join(home, ".local", "share", "desktop-badge"); +const outputPath = join(outputDir, "agent-progress.json"); +const intervalMs = 2_000; + +function readJson(path) { + try { return JSON.parse(readFileSync(path, "utf8")); } + catch { return null; } +} + +function processes() { + try { + return execFileSync("/bin/ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8" }) + .split("\n") + .map((line) => { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/); + return match ? { pid: Number(match[1]), ppid: Number(match[2]), command: match[3] } : null; + }) + .filter(Boolean); + } catch { + return []; + } +} + +function findClaudeSession(workerPid) { + const row = processes().find((p) => p.ppid === workerPid && /(^|\/)claude\s+-p\b/.test(p.command)); + if (!row) return null; + const session = readJson(join(home, ".claude", "sessions", `${row.pid}.json`)); + return session?.sessionId ? { pid: row.pid, ...session } : null; +} + +function loadTasks(sessionId) { + const dir = join(home, ".claude", "tasks", sessionId); + let names = []; + try { names = readdirSync(dir).filter((name) => /^\d+\.json$/.test(name)); } + catch { return []; } + return names + .map((name) => readJson(join(dir, name))) + .filter((task) => task && task.subject) + .sort((a, b) => Number(a.id) - Number(b.id)); +} + +function latestAssistantActivity(session) { + if (!session?.sessionId || !session?.cwd) return { activity: "", lastTool: "" }; + const project = session.cwd.replaceAll("/", "-"); + const path = join(home, ".claude", "projects", project, `${session.sessionId}.jsonl`); + let lines; + try { lines = readFileSync(path, "utf8").trim().split("\n"); } + catch { return { activity: "", lastTool: "" }; } + let activity = ""; + let lastTool = ""; + for (let index = lines.length - 1; index >= 0 && (!activity || !lastTool); index -= 1) { + let entry; + try { entry = JSON.parse(lines[index]); } catch { continue; } + if (entry?.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue; + for (const block of [...entry.message.content].reverse()) { + if (!lastTool && block?.type === "tool_use") lastTool = String(block.name || ""); + if (!activity && block?.type === "text") { + activity = String(block.text || "") + .replace(/\s+/g, " ") + .replace(/^#+\s*/, "") + .trim() + .slice(0, 180); + } + } + } + return { activity, lastTool }; +} + +function phaseFor(tasks) { + const activeText = tasks + .filter((task) => task.status === "in_progress") + .map((task) => `${task.subject} ${task.activeForm || ""}`) + .join(" ") + .toLowerCase(); + const rules = [ + [/inspect|capture hygiene|\bqa\b/, "INSPECTING"], + [/outbox|deliver|manifest/, "DELIVERING"], + [/render|generat(e|ing).*video/, "RENDERING"], + [/narrat|pacing/, "NARRATING"], + [/author|writing|\bwrite\b/, "AUTHORING"], + [/preflight|study|research|pattern|probe|selector|mapping/, "PATHFINDING"], + ]; + return rules.find(([pattern]) => pattern.test(activeText))?.[1] + || (tasks.length && tasks.every((task) => task.status === "completed") ? "COMPLETE" : "WORKING"); +} + +function latestFailure(state) { + return Object.entries(state?.done || {}) + .filter(([, record]) => record?.status === "failed") + .map(([taskGid, record]) => ({ taskGid, ...record })) + .sort((a, b) => Number(b.at || 0) - Number(a.at || 0))[0] || null; +} + +function publish() { + const state = readJson(statePath); + const active = state?.active; + let payload; + if (!active?.pid) { + const recovery = state?.recovery; + const recovering = recovery && [ + "checking-browser", "reloading-browser", "queued", "relaunching", + ].includes(recovery.status); + const failure = latestFailure(state); + payload = recovering ? { + schema:"iris-agent-progress/v1", + updatedAt:new Date().toISOString(), + state:"working", + phase:"RECOVERING", + taskGid:String(recovery.taskGid || ""), + mission:String(recovery.mission || "Mission"), + completed:0, + total:1, + active:[recovery.activity || "Recovering stopped mission"], + activity:recovery.detail || recovery.reason || "Recovering stopped mission", + recoveryAttempt:Number(recovery.attempts || 0), + recoveryMaximum:Number(recovery.maximum || 0), + } : failure || recovery?.status === "blocked" || recovery?.status === "exhausted" ? { + schema:"iris-agent-progress/v1", + updatedAt:new Date().toISOString(), + state:"failed", + phase:"FAILED", + taskGid:String(failure?.taskGid || recovery?.taskGid || ""), + mission:String(failure?.name || recovery?.mission || "Mission"), + completed:0, + total:1, + active:[], + activity:String(recovery?.activity || failure?.detail || failure?.reason || "Mission needs attention"), + failureReason:String(failure?.reason || recovery?.reason || "unknown"), + log:String(failure?.log || ""), + } : { + schema:"iris-agent-progress/v1", + updatedAt:new Date().toISOString(), + state:"idle", + phase:"IDLE", + completed:0, + total:0, + active:[], + }; + } else { + const session = findClaudeSession(Number(active.pid)); + const tasks = session ? loadTasks(session.sessionId) : []; + const activeTasks = tasks.filter((task) => task.status === "in_progress"); + const completed = tasks.filter((task) => task.status === "completed").length; + const detail = latestAssistantActivity(session); + payload = { + schema: "iris-agent-progress/v1", + updatedAt: new Date().toISOString(), + state: "working", + phase: phaseFor(tasks), + taskGid: String(active.taskGid || ""), + mission: String(active.name || "Mission"), + workerPid: Number(active.pid), + sessionId: String(session?.sessionId || ""), + completed, + total: tasks.length, + active: activeTasks.map((task) => task.activeForm || task.subject).slice(0, 3), + activity: detail.activity, + lastTool: detail.lastTool, + }; + } + mkdirSync(outputDir, { recursive: true }); + const temp = `${outputPath}.${process.pid}.tmp`; + writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o644 }); + renameSync(temp, outputPath); +} + +publish(); +setInterval(publish, intervalMs); diff --git a/captutor/screenplays/talking-taco.mjs b/captutor/screenplays/talking-taco.mjs new file mode 100644 index 000000000..f30149177 --- /dev/null +++ b/captutor/screenplays/talking-taco.mjs @@ -0,0 +1,491 @@ +// talking-taco — from a blank project to a generated image, to a generated +// video: Fuser's whole creative loop in one connected canvas. +// +// This is the first Captutor lesson that chains TWO real generations. It opens +// on a genuinely empty project (no seed flow), adds a Gemini Image node and +// writes a text-only prompt, generates a talking taco, then adds a Kling 3.0 +// Video node, wires the taco image straight into its Image input, adds a short +// motion prompt, and generates the animation. The edge between the two nodes +// is the whole point: it is the visible, undeniable proof that the video was +// made FROM the generated image, not some other clip. +// +// English is the only locale this lesson ships in — the brief calls for an +// English-narrated, English-captioned tutorial, so `say` fields are plain +// strings rather than the `{ en, "zh-CN": ... }` maps other lessons use. +import fuserBrandChrome, { fuserEffectTheme } from "../themes/fuser.mjs"; +import { + assertTutorialLayout, frameTutorialNodes, installTutorialLayout, removeTutorialLayout, + setTutorialZoom, +} from "../lib/tutorial-layout.mjs"; + +const WORKSPACE = "https://app.fuser.studio/w/me"; + +const GEMINI_NODE = ".react-flow__node-FalGeminiImageNode"; +const KLING_NODE = ".react-flow__node-FalKling30VideoNode"; +const GEMINI_PROMPT = `${GEMINI_NODE} textarea`; +const KLING_PROMPT = `${KLING_NODE} textarea`; +const GEMINI_GENERATED_IMAGE = `${GEMINI_NODE} img[alt="Displaying input"]`; +const KLING_GENERATED_VIDEO = `${KLING_NODE} video`; +const EXECUTE = '[data-ph-capture-attribute-node-toolbar-action="execute_node"]'; +// The left rail's node-adding "+" carries no aria-label or test id — every +// toolbar icon shares `data-is-toolbar-item="true"` — but it is reliably the +// FIRST of them (Add Node, Assets, Terminal, Templates, Settings, in that +// fixed order), so an index is the stable handle rather than a pixel guess. +const ADD_NODE = `js=document.querySelectorAll('[data-is-toolbar-item="true"]')[0]`; + +const TACO_IMAGE_PROMPT = + "A cheerful cartoon taco character with big googly eyes and a wide open " + + "mouth mid-laugh, sitting on a colorful kitchen counter, vibrant flat " + + "illustration style, clean studio lighting, simple background"; +const TACO_MOTION_PROMPT = + "The taco opens and closes its mouth as if talking excitedly, waving its " + + "arms, gentle bobbing motion, no camera movement"; + +// Fuser renders each node property as a row: a label (exact text, no +// children) beside a pair of connector handles. Matching the LABEL and +// walking up to the nearest handle survives node reflows, hidden-properties +// toggles, and property reordering — a fixed handle index would not. Reused +// for both the image node's single true output and the video node's single +// true image-input, each confirmed by hand against the live DOM before this +// screenplay was written. +const handleNear = (nodeSelector, labelText, handleClass) => `js=(() => { + const node = document.querySelector(${JSON.stringify(nodeSelector)}); + if (!node) return null; + const label = [...node.querySelectorAll('*')].find((el) => + el.children.length === 0 && (el.textContent || '').trim() === ${JSON.stringify(labelText)}); + let row = label, hops = 0; + while (row && hops < 6) { + const handle = row.querySelector(${JSON.stringify(handleClass)}); + if (handle) return handle; + row = row.parentElement; hops += 1; + } + return null; +})()`; +const GEMINI_OUTPUT_HANDLE = handleNear(GEMINI_NODE, "Generated Image", ".react-flow__handle-right.source"); +const KLING_IMAGE_INPUT_HANDLE = handleNear(KLING_NODE, "Image", ".react-flow__handle-left.target"); + +let creditsBeforeRun = ""; + +// Fuser's node fields need a click to SELECT the node before a second click +// can actually focus a field inside it — the first click's mousedown lands on +// the (still-unselected) node wrapper, not the textarea. A single click, even +// freshly re-measured, can therefore land on an unfocused field. Click twice, +// then verify focus and the committed value before moving on, retrying once +// if Fuser has not caught up yet. This also survives the render's normal +// gap between typing and the next beat's execute click. +async function typePrompt(ctx, selector, text) { + const { cdp, click, sleep } = ctx; + for (let attempt = 1; attempt <= 3; attempt += 1) { + await click(selector); + await sleep(200); + if (!(await cdp.eval(`document.activeElement === document.querySelector(${JSON.stringify(selector)})`))) { + await click(selector); + await sleep(200); + } + await cdp.type(text); + await sleep(300); + const value = await cdp.eval(`document.querySelector(${JSON.stringify(selector)})?.value || ''`); + if (value === text) return; + } + throw new Error(`could not commit prompt text into ${selector}`); +} + +// The node properties inspector is not part of this lesson. Its closed state +// persists while nodes are selected, so establish that state off camera and +// leave the canvas, inline fields, and floating Generate control as the only +// filmed interaction. `View properties` is the closed-state affordance. +async function closePropertiesInspector(cdp) { + const closed = await cdp.eval(`[...document.querySelectorAll('button')] + .some((button) => (button.innerText || '').trim() === 'View properties')`); + if (closed) return; + const point = await cdp.eval(`(() => { + const button = [...document.querySelectorAll('button')].find((element) => { + const rect = element.getBoundingClientRect(); + return rect.left > innerWidth - 55 && rect.top > 65 && rect.top < 110 && + rect.width > 10 && rect.width < 30; + }); + if (!button) return null; + const rect = button.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + })()`); + // A genuinely blank canvas has no selected node, so it exposes neither the + // inspector nor its closed-state `View properties` affordance. That is + // already the clean state this helper is meant to establish. + if (!point) return; + await cdp.mouse("mousePressed", point.x, point.y); + await cdp.mouse("mouseReleased", point.x, point.y); + await cdp.waitFor(`[...document.querySelectorAll('button')] + .some((button) => (button.innerText || '').trim() === 'View properties')`); +} + +async function suppressDuplicateIrisPresence(cdp) { + await cdp.eval(`(() => { + window.__captutorPresenceObserver?.disconnect(); + const suppress = () => { + for (const element of document.querySelectorAll('[title]')) { + if (element.title !== 'iris@fuser.studio') continue; + element.dataset.captutorDuplicatePresence = 'true'; + element.style.display = 'none'; + } + }; + suppress(); + window.__captutorPresenceObserver = new MutationObserver(suppress); + window.__captutorPresenceObserver.observe(document.documentElement, { + childList: true, subtree: true, + }); + return true; + })()`); +} + +// Selecting a node to reveal its floating toolbar can occasionally lose the +// pointer activation to Fuser's own hover handling (the same edge case +// documented in image-generation-workflow.mjs). Reselect and retry with a +// trusted Enter on the focused Generate button rather than double-firing it. +async function runNode(ctx, nodeSelector, startedExpr) { + const { cdp, click, sleep } = ctx; + // Belt-and-suspenders: a node executed with an empty required prompt comes + // back as a hard "Operation failed" from Fuser rather than a queued run, and + // was the one error observed to occasionally knock the whole flow back to + // the workspace. `typePrompt` already guarantees the field committed, but + // never click Generate against an empty required field. + const promptValue = await cdp.eval( + `document.querySelector(${JSON.stringify(nodeSelector)} + ' textarea')?.value ?? null`, + ); + if (promptValue === "") throw new Error(`refusing to execute ${nodeSelector}: prompt is empty`); + const select = async (moveMs) => { + await click(nodeSelector, { moveMs, anchorY: 0.01 }); + await sleep(220); + if (!(await cdp.eval(`!!document.querySelector(${JSON.stringify(EXECUTE)})`))) { + const border = await cdp.eval(`(() => { + const rect = document.querySelector(${JSON.stringify(nodeSelector)}).getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + 5 }; + })()`); + await cdp.mouse("mousePressed", border.x, border.y); + await cdp.mouse("mouseReleased", border.x, border.y); + await sleep(220); + } + await cdp.waitFor( + `document.querySelector(${JSON.stringify(EXECUTE)}) && !document.querySelector(${JSON.stringify(EXECUTE)}).disabled`, + ); + }; + await select(480); + await closePropertiesInspector(cdp); + await click(EXECUTE, { moveMs: 480 }); + await sleep(900); + if (!(await cdp.eval(startedExpr))) { + await select(260); + await closePropertiesInspector(cdp); + await cdp.eval(`document.querySelector(${JSON.stringify(EXECUTE)}).focus()`); + await cdp.key("Enter", "Enter", 13); + } + await cdp.waitFor(startedExpr, { timeoutMs: 10000, everyMs: 100 }); +} + +const creditsExpr = `[...document.querySelectorAll('button')] + .map((button) => (button.innerText || '').trim()) + .find((text) => /^[\\d,]+✦$/.test(text)) || ''`; + +export default { + slug: "talking-taco", + voice: "jeffrey", + window: "Fuser", + desktopFrame: true, + match: "fuser.studio", + theme: "light", + effectTheme: fuserEffectTheme, + brandChrome: fuserBrandChrome, + billable: true, + fps: 60, + title: "Learn Fuser by making a Talking Taco", + subtitle: "A fresh project → a generated image → a generated video", + openingCard: { + title: "Learn Fuser", + durationMs: 2400, + transition: "slide", + }, + closingCard: { + title: "One prompt became an image. That image became a video.", + durationMs: 2400, + transition: "genie", + }, + acceptance: { + minimumDurationSec: 60, + requireOpeningCard: true, + requireEndingCard: true, + requireBrandChrome: true, + loudnessLufs: [-18, -14], + requiredChecks: [ + "fresh_blank_project_opened", + "image_node_added", + "image_prompt_entered", + "image_generation_started", + "generated_image_returned", + "video_node_added", + "image_to_video_edge_connected", + "video_prompt_entered", + "video_generation_started", + "generated_video_returned", + "final_provenance_tableau_complete", + "ui-legibility-score", + "balanced-layout-score", + ], + }, + + // A genuinely fresh project every take: open the workspace, create a blank + // project (Fuser navigates straight to /flow/ on prod), and wait for a + // fully hydrated canvas before the camera rolls. Dismiss any first-run + // onboarding off camera — the narration replaces it. + setup: async ({ cdp, locale, setLocale, click, s, sleep }) => { + await setLocale(cdp, locale, WORKSPACE); + await cdp.waitFor(`document.querySelector('${s.blankProject}')`); + await click(s.blankProject); + await cdp.waitFor("location.pathname.startsWith('/flow/')"); + await cdp.waitFor("document.querySelector('.react-flow')"); + await sleep(600); + + const buttonExpression = (label) => + `[...document.querySelectorAll('button')].some((button) => (button.innerText || '').trim() === ${JSON.stringify(label)})`; + const maybeClickOnboarding = async (label, waitMs = 0) => { + const deadline = Date.now() + waitMs; + while (!(await cdp.eval(buttonExpression(label)))) { + if (Date.now() >= deadline) return false; + await sleep(120); + } + await click(`text=${label}`); + return true; + }; + await maybeClickOnboarding("Got it", 1500); + await maybeClickOnboarding("Skip", 1500); + await closePropertiesInspector(cdp); + await suppressDuplicateIrisPresence(cdp); + await installTutorialLayout(cdp, [GEMINI_NODE, KLING_NODE]); + }, + + teardown: async ({ cdp }) => { + await removeTutorialLayout(cdp); + return cdp.eval(`(() => { + window.__captutorPresenceObserver?.disconnect(); + delete window.__captutorPresenceObserver; + for (const element of document.querySelectorAll('[data-captutor-duplicate-presence]')) { + element.style.display = ''; + delete element.dataset.captutorDuplicatePresence; + } + return true; + })()`); + }, + + beats: [ + { + say: "Learn Fuser by making a Talking Taco.", + logic: "Open on the genuinely empty canvas the whole lesson builds on.", + cursorIntent: "Park in the middle of the empty pane.", + do: async ({ cdp, check, point }) => { + await point(".react-flow__pane", { moveMs: 620 }); + check("fresh_blank_project_opened", await cdp.eval(`({ + pathname: location.pathname, + nodeCount: document.querySelectorAll('.react-flow__node').length, + })`)); + }, + }, + { + say: "Start in a fresh project — an empty canvas, ready for your first idea.", + do: async ({ point }) => point(".react-flow__pane", { moveMs: 520, anchorX: 0.5, anchorY: 0.4 }), + }, + { + say: "Open the node picker, and search for Gemini Image — Fuser's fast image generator.", + do: async ({ cdp, click, type, s, t }) => { + await click(ADD_NODE); + await cdp.waitFor(`document.querySelector('${s.nodeSearch}')`); + await type(s.nodeSearch, t("flow.nodes.FalGeminiImageNode.name")); + }, + }, + { + say: "Press Enter, and it drops right onto the canvas.", + do: async (ctx) => { + const { cdp, check, click, s, sleep } = ctx; + // Refocus the search field before Enter — a prior click elsewhere can + // steal focus, and a synthetic Enter with no field focused submits + // nothing (see drive-ui.md). + await click(s.nodeSearch); + await cdp.key("Enter", "Enter", 13); + await cdp.waitFor("document.querySelectorAll('.react-flow__node').length === 1"); + await sleep(500); + await setTutorialZoom(ctx, 80); + await sleep(320); + await frameTutorialNodes(ctx, [{ selector:GEMINI_NODE, title:"Gemini Image" }]); + await assertTutorialLayout(ctx, [GEMINI_NODE]); + check("image_node_added", await cdp.eval(`({ + nodeClass: document.querySelector('.react-flow__node')?.className, + })`)); + }, + }, + { + say: "Describe the image you want. Let's make a talking taco: bold, cheerful, mouth wide open mid-laugh.", + do: async (ctx) => { + const { cdp, check } = ctx; + await typePrompt(ctx, GEMINI_PROMPT, TACO_IMAGE_PROMPT); + check("image_prompt_entered", { text: await cdp.eval(`document.querySelector(${JSON.stringify(GEMINI_PROMPT)}).value`) }); + }, + }, + { + say: "Generate — and Fuser turns that prompt into a real image in seconds.", + do: async (ctx) => { + const { cdp, check } = ctx; + creditsBeforeRun = await cdp.eval(creditsExpr); + const started = `(() => { + const credits = ${creditsExpr}; + const node = document.querySelector(${JSON.stringify(GEMINI_NODE)}); + return credits !== ${JSON.stringify(creditsBeforeRun)} || + !!node?.querySelector('[aria-busy=true],[role=progressbar],.animate-spin'); + })()`; + await runNode(ctx, GEMINI_NODE, started); + check("image_generation_started", { creditsBeforeRun }); + }, + }, + { + say: "There it is — a fresh, generated talking taco, ready to bring to life.", + do: async (ctx) => { + const { cdp, check, click, point, spotlight, sleep } = ctx; + await cdp.waitFor( + `(() => { + const image = document.querySelector(${JSON.stringify(GEMINI_GENERATED_IMAGE)}); + return !!image?.complete && image.naturalWidth > 0; + })()`, + { timeoutMs: 120000, everyMs: 250 }, + ); + const returned = await cdp.eval(`(() => { + const image = document.querySelector(${JSON.stringify(GEMINI_GENERATED_IMAGE)}); + return { src: image.currentSrc || image.src, width: image.naturalWidth, height: image.naturalHeight }; + })()`); + check("generated_image_returned", returned); + await frameTutorialNodes(ctx, [{ selector:GEMINI_NODE, title:"Gemini Image" }]); + await assertTutorialLayout(ctx, [GEMINI_NODE]); + // Deselect so the node's own toolbar/side panel closes before framing. + await click(".react-flow__pane", { moveMs: 260, anchorX: 0.90, anchorY: 0.10 }); + await sleep(400); + await point(".react-flow__pane", { moveMs: 420, anchorX: 0.30, anchorY: 0.88 }); + await spotlight(GEMINI_GENERATED_IMAGE, { + label: "Generated image", dim: 0.26, ring: true, feather: 30, durationMs: 3200, + }); + }, + }, + { + say: "Now add a second node: Kling 3.0 Video, Fuser's image-to-video model.", + do: async ({ cdp, click, type, s, t }) => { + await click(ADD_NODE); + await cdp.waitFor(`document.querySelector('${s.nodeSearch}')`); + await type(s.nodeSearch, t("flow.nodes.FalKling30VideoNode.name")); + }, + }, + { + say: "Press Enter to add it, then slide it next to the image so both stay in view.", + do: async (ctx) => { + const { cdp, check, click, s, sleep } = ctx; + await click(s.nodeSearch); + await cdp.key("Enter", "Enter", 13); + await cdp.waitFor("document.querySelectorAll('.react-flow__node').length === 2"); + await sleep(500); + await click(".react-flow__pane", { moveMs: 260, anchorX: 0.06, anchorY: 0.94 }); + await sleep(300); + await setTutorialZoom(ctx, 80); + await sleep(320); + await frameTutorialNodes(ctx, [ + { selector:GEMINI_NODE, title:"Gemini Image" }, + { selector:KLING_NODE, title:"Kling 3.0 Video" }, + ]); + await assertTutorialLayout(ctx, [GEMINI_NODE, KLING_NODE]); + check("video_node_added", await cdp.eval(`({ + nodeCount: document.querySelectorAll('.react-flow__node').length, + })`)); + }, + }, + { + say: "Connect the taco image's output straight into the video node's Image input — that is the exact frame it will animate.", + do: async ({ cdp, check, drag, outline }) => { + await drag(GEMINI_OUTPUT_HANDLE, KLING_IMAGE_INPUT_HANDLE, { moveMs: 520, dragMs: 720 }); + await cdp.waitFor("document.querySelectorAll('.react-flow__edge').length === 1"); + const rect = await outline(".react-flow__edge", { + label: "Image feeds the video node", feather: 24, durationMs: 3200, + }); + check("image_to_video_edge_connected", { edgeCount: 1, rect }); + }, + }, + { + say: "Add a short motion prompt describing how the taco should move and talk.", + do: async (ctx) => { + const { cdp, check } = ctx; + await typePrompt(ctx, KLING_PROMPT, TACO_MOTION_PROMPT); + check("video_prompt_entered", { text: await cdp.eval(`document.querySelector(${JSON.stringify(KLING_PROMPT)}).value`) }); + }, + }, + { + say: "Generate the video, and Fuser animates that still image into motion.", + do: async (ctx) => { + const { cdp, check } = ctx; + creditsBeforeRun = await cdp.eval(creditsExpr); + const started = `(() => { + const credits = ${creditsExpr}; + const node = document.querySelector(${JSON.stringify(KLING_NODE)}); + return credits !== ${JSON.stringify(creditsBeforeRun)} || + !!node?.querySelector('[aria-busy=true],[role=progressbar],.animate-spin'); + })()`; + await runNode(ctx, KLING_NODE, started); + check("video_generation_started", { creditsBeforeRun }); + }, + }, + { + say: "And here it is — the taco, talking and gesturing, generated straight from the image you made a moment ago.", + do: async (ctx) => { + const { cdp, check, click, point, spotlight, sleep } = ctx; + await cdp.waitFor( + `(() => { + const video = document.querySelector(${JSON.stringify(KLING_GENERATED_VIDEO)}); + return !!video && video.readyState >= 2 && !!(video.currentSrc || video.src); + })()`, + { timeoutMs: 600000, everyMs: 1000 }, + ); + const returned = await cdp.eval(`(() => { + const video = document.querySelector(${JSON.stringify(KLING_GENERATED_VIDEO)}); + return { src: video.currentSrc || video.src, duration: video.duration }; + })()`); + check("generated_video_returned", returned); + await frameTutorialNodes(ctx, [ + { selector:GEMINI_NODE, title:"Gemini Image" }, + { selector:KLING_NODE, title:"Kling 3.0 Video" }, + ]); + await assertTutorialLayout(ctx, [GEMINI_NODE, KLING_NODE]); + await click(".react-flow__pane", { moveMs: 260, anchorX: 0.90, anchorY: 0.10 }); + await sleep(400); + await point(".react-flow__pane", { moveMs: 420, anchorX: 0.72, anchorY: 0.88 }); + await spotlight(KLING_GENERATED_VIDEO, { + label: "Generated from the taco image", dim: 0.24, ring: true, + labelPosition: "side", feather: 30, durationMs: 3800, + }); + }, + }, + { + say: "That's Fuser end to end: one prompt became a generated image, and that same image became a generated video — all on one connected canvas.", + do: async (ctx) => { + const { cdp, check, point } = ctx; + await frameTutorialNodes(ctx, [ + { selector:GEMINI_NODE, title:"Gemini Image" }, + { selector:KLING_NODE, title:"Kling 3.0 Video" }, + ]); + await assertTutorialLayout(ctx, [GEMINI_NODE, KLING_NODE]); + await point(".react-flow__pane", { moveMs: 720, anchorX: 0.5, anchorY: 0.94 }); + check("final_provenance_tableau_complete", await cdp.eval(`({ + nodes: document.querySelectorAll('.react-flow__node').length, + edges: document.querySelectorAll('.react-flow__edge').length, + imageLoaded: (() => { + const image = document.querySelector(${JSON.stringify(GEMINI_GENERATED_IMAGE)}); + return !!image?.complete && image.naturalWidth > 0; + })(), + videoReady: (() => { + const video = document.querySelector(${JSON.stringify(KLING_GENERATED_VIDEO)}); + return !!video && video.readyState >= 2; + })(), + })`)); + }, + }, + ], +}; diff --git a/captutor/test/cdp-health.test.mjs b/captutor/test/cdp-health.test.mjs new file mode 100644 index 000000000..60f4463e6 --- /dev/null +++ b/captutor/test/cdp-health.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { BrowserCrashError, Session } from "../lib/cdp.mjs"; + +class FakeWebSocket { + static instances = []; + + constructor() { + this.listeners = new Map(); + this.readyState = 0; + this.respond = true; + FakeWebSocket.instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + this.emit("open", {}); + }); + } + + addEventListener(type, listener, options = {}) { + const wrapped = options.once + ? (event) => { + this.listeners.get(type)?.delete(wrapped); + listener(event); + } + : listener; + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type).add(wrapped); + } + + emit(type, event) { + for (const listener of [...(this.listeners.get(type) || [])]) listener(event); + } + + message(payload) { + this.emit("message", { data:JSON.stringify(payload) }); + } + + send(raw) { + const request = JSON.parse(raw); + if (!this.respond) return; + queueMicrotask(() => this.message({ + id:request.id, + result:request.method === "Runtime.evaluate" + ? { result:{ value:{ readyState:"complete", href:"https://app.fuser.studio/flow/test" } } } + : {}, + })); + } + + close() { + this.readyState = 3; + this.emit("close", {}); + } +} + +test("CDP health accepts a responsive renderer and catches its crash event", async (t) => { + const RealWebSocket = globalThis.WebSocket; + globalThis.WebSocket = FakeWebSocket; + t.after(() => { globalThis.WebSocket = RealWebSocket; }); + + const session = new Session("ws://fake.test/page/one"); + t.after(() => session.close()); + await session.monitorCrashes(); + assert.equal((await session.assertHealthy()).readyState, "complete"); + + const ws = FakeWebSocket.instances.at(-1); + ws.message({ method:"Inspector.targetCrashed", params:{ status:"crashed" } }); + await assert.rejects( + session.assertHealthy("beat 3"), + (error) => error instanceof BrowserCrashError + && error.code === "BROWSER_RENDERER_CRASH" + && error.details.signal === "Inspector.targetCrashed", + ); +}); + +test("CDP health converts an unresponsive renderer into a bounded crash failure", async (t) => { + const RealWebSocket = globalThis.WebSocket; + globalThis.WebSocket = FakeWebSocket; + t.after(() => { globalThis.WebSocket = RealWebSocket; }); + + const session = new Session("ws://fake.test/page/two"); + t.after(() => session.close()); + await session.monitorCrashes(); + FakeWebSocket.instances.at(-1).respond = false; + + const started = Date.now(); + await assert.rejects( + session.assertHealthy("pre-record", { timeoutMs:25 }), + (error) => error instanceof BrowserCrashError + && error.details.signal === "CDP.unresponsive", + ); + assert.ok(Date.now() - started < 500, "health failure should be bounded"); +}); diff --git a/captutor/test/cursor.test.mjs b/captutor/test/cursor.test.mjs new file mode 100644 index 000000000..b0cb028cc --- /dev/null +++ b/captutor/test/cursor.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { pagePointToScreen } from "../lib/cursor.mjs"; + +test("native cursor hotspot maps page pixels through browser chrome exactly", () => { + const geometry = { + screenX:40, + screenY:24, + outerWidth:1000, + outerHeight:760, + innerWidth:984, + innerHeight:650, + }; + // Eight points of symmetric side frame and 102 points of top chrome. + assert.deepEqual(pagePointToScreen(geometry, { x:200, y:90 }), { + x:248, + y:216, + }); +}); + +test("native cursor mapping does not invent negative browser borders", () => { + const geometry = { + screenX:-1280, + screenY:0, + outerWidth:900, + outerHeight:700, + innerWidth:920, + innerHeight:720, + }; + assert.deepEqual(pagePointToScreen(geometry, { x:20, y:30 }), { + x:-1260, + y:30, + }); +}); diff --git a/captutor/test/iris-heartbeat-recovery.test.mjs b/captutor/test/iris-heartbeat-recovery.test.mjs new file mode 100644 index 000000000..017cade79 --- /dev/null +++ b/captutor/test/iris-heartbeat-recovery.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + beginRecovery, latestRecoverableFailure, queueRecovery, +} from "../ops/iris-heartbeat-recovery.mjs"; + +test("heartbeat selects the newest failed Captutor mission only while idle", () => { + const state = { + active:null, + done:{ + old:{ name:"Old", kind:"captutor", status:"failed", at:10 }, + code:{ name:"Code", kind:"pr", status:"failed", at:30 }, + taco:{ name:"Talking Taco", kind:"captutor", status:"failed", at:20 }, + }, + }; + assert.equal(latestRecoverableFailure(state).taskGid, "taco"); + state.active = { taskGid:"live" }; + assert.equal(latestRecoverableFailure(state), null); +}); + +test("heartbeat queues exactly one retry and preserves an audit trail", () => { + const state = { + active:null, + done:{ taco:{ + name:"Talking Taco", kind:"captutor", status:"failed", + reason:"missing-outbox-artifacts", detail:"worker exited 0", at:20, + } }, + }; + const failure = latestRecoverableFailure(state); + assert.equal(beginRecovery(state, failure, 100, 1), true); + const recovery = queueRecovery(state, failure, 101); + assert.equal(recovery.status, "queued"); + assert.equal(recovery.attempts, 1); + assert.equal(state.done.taco, undefined); + assert.equal(state.recoveries.taco.reason, "missing-outbox-artifacts"); + + state.done.taco = { ...failure, status:"failed", at:200 }; + assert.equal(beginRecovery(state, latestRecoverableFailure(state), 201, 1), false); + assert.equal(state.recovery.status, "exhausted"); +}); diff --git a/captutor/test/stage-contract.test.mjs b/captutor/test/stage-contract.test.mjs new file mode 100644 index 000000000..bdd1f09f7 --- /dev/null +++ b/captutor/test/stage-contract.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { assertHiDPIStage, StageContractError } from "../lib/stage-contract.mjs"; +import { normalizeStageBrand, parseStageFlags } from "../lib/stage-mode.mjs"; + +test("Stage brand is explicit and defaults to the dimensional Fuser treatment", () => { + assert.equal(normalizeStageBrand(), "fuser"); + assert.equal(normalizeStageBrand("FUSER"), "fuser"); + assert.equal(normalizeStageBrand("classic"), "classic"); + assert.throws(() => normalizeStageBrand("unknown"), /unsupported Captutor Stage brand/); +}); + +test("Stage flags preserve the Captutor command with and without an explicit brand", () => { + assert.deepEqual(parseStageFlags(["render", "intro"]), { + vertical:false, brand:"fuser", args:["render", "intro"], + }); + assert.deepEqual(parseStageFlags(["--vertical", "--brand", "classic", "render", "intro", "--locale", "fr"]), { + vertical:true, brand:"classic", args:["render", "intro", "--locale", "fr"], + }); + assert.throws(() => parseStageFlags(["--brand", "--vertical", "render", "intro"]), /needs a value/); +}); + +test("fleet mission takes require the Stage wrapper", () => { + assert.throws( + () => assertHiDPIStage({ required:true, stageMode:false }), + (error) => error instanceof StageContractError + && error.code === "CAPTUTOR_HIDPI_STAGE_REQUIRED", + ); +}); + +test("landscape Stage accepts the real 1280x720 2x display", () => { + assert.deepEqual( + assertHiDPIStage({ + required:true, + stageMode:true, + screen:{ width:1280, height:720, dpr:2 }, + }), + { width:1280, height:720, dpr:2 }, + ); +}); + +test("ordinary desktop geometry cannot masquerade as Stage", () => { + assert.throws( + () => assertHiDPIStage({ + required:true, + stageMode:true, + screen:{ width:2560, height:1440, dpr:1 }, + }), + /HiDPI Stage is not active/, + ); +}); + +test("portrait missions require the rotated 2x Stage display", () => { + assert.doesNotThrow(() => assertHiDPIStage({ + required:true, + stageMode:true, + vertical:true, + screen:{ width:720, height:1280, dpr:2 }, + })); +}); diff --git a/captutor/test/tutorial-layout.test.mjs b/captutor/test/tutorial-layout.test.mjs new file mode 100644 index 000000000..4d925781e --- /dev/null +++ b/captutor/test/tutorial-layout.test.mjs @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { tutorialLayoutScores } from "../lib/tutorial-layout.mjs"; + +const measured = (nodes) => ({ + eval: async () => ({ + viewport:{ width:1190, height:630 }, + safe:{ left:88, top:76, right:1118, bottom:480 }, + nodes, + }), +}); + +test("ui-legibility-score accepts full labels in the safe region", async () => { + const scores = await tutorialLayoutScores(measured([{ + selector:".image", truncated:[], layoutWidth:336, + rect:{ left:468.5, top:104, right:737.5, bottom:452, width:269, height:348, cx:603, cy:278 }, + }]), [".image"]); + assert.equal(scores["ui-legibility-score"].score, 100); + assert.equal(scores["balanced-layout-score"].score, 100); +}); + +test("scores reject truncated labels and chat-zone collisions", async () => { + const scores = await tutorialLayoutScores(measured([{ + selector:".image", truncated:["Nano Banan…"], + rect:{ left:450, top:180, right:674, bottom:560, width:224, height:380, cx:562, cy:370 }, + }]), [".image"]); + assert.ok(scores["ui-legibility-score"].score < scores["ui-legibility-score"].threshold); + assert.ok(scores["balanced-layout-score"].score < scores["balanced-layout-score"].threshold); +}); + +test("balanced-layout-score rewards an aligned two-node teaching tableau", async () => { + const scores = await tutorialLayoutScores(measured([ + { + selector:".image", truncated:[], + layoutWidth:336, + rect:{ left:235, top:104, right:571, bottom:452, width:336, height:348, cx:403, cy:278 }, + }, + { + selector:".video", truncated:[], + layoutWidth:336, + rect:{ left:635, top:104, right:971, bottom:452, width:336, height:348, cx:803, cy:278 }, + }, + ]), [".image", ".video"]); + assert.equal(scores["ui-legibility-score"].score, 100); + assert.equal(scores["balanced-layout-score"].score, 100); +}); diff --git a/slab/bin/frame-mcp.mjs b/slab/bin/frame-mcp.mjs index 10b530a3e..c40a1e64f 100755 --- a/slab/bin/frame-mcp.mjs +++ b/slab/bin/frame-mcp.mjs @@ -23,6 +23,7 @@ import { fileURLToPath } from "node:url"; import { homedir, tmpdir } from "node:os"; import { httpPort, serveHttp, serveStdio } from "../../toolchain/mcp/http-front.mjs"; import { clickPoint, hoverPoint, sendKeys } from "./macos.mjs"; +import { buildHoverProbes, changesNearPoint } from "../lib/frame-hover-atlas.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, "../.."); @@ -157,6 +158,7 @@ function inferenceEvidence(env) { // ── the capture tool: frame a machine, return image + digest ──────────────── const stagedClicks = new Map(); const recentActionTrails = new Map(); +const recentFrames = new Map(); const visionCache = new Map(); async function captureFrame({ machine, ocr = true, fast = false, screen = false, cursor = true, cursorAt, targetAt, targetId, manualCheck, pressAt, pressCount = 1, pressTitle, actionOnly = false, clearTarget = false, clearOverlays = false, quietOverlay = false, crop, baseline = false, diff = false } = {}) { @@ -225,7 +227,9 @@ async function toolFrame(options = {}) { // FRAME establishes the stable observation baseline used by later reframes. async function toolInitialFrame(options = {}) { - return toolFrame({ ...options, baseline: true }); + const capture = await captureFrame({ ...options, baseline: true }); + recentFrames.set(options.machine, capture.env); + return frameContent(capture, options.machine); } // REFRAME is change-driven: one silent full-window diff probe advances the @@ -810,6 +814,84 @@ async function toolHover({ machine, x, y, width = 720, height = 520, ocr = true, baseline: true, quietOverlay: true }); } +function hoverAtlasText(mode, results) { + const observed = results.filter((result) => result.near.cells > 0).length; + const lines = [ + `HOVER AFFORDANCE ATLAS — ${mode}: ${results.length} no-click probes, ${observed} local visual changes`, + "Possible actions are evidence, not actions performed:", + ]; + for (const result of results) { + const state = result.near.cells > 0 ? "observed-hover-change" : "candidate"; + const label = result.probe.label ? ` “${result.probe.label}”` : ""; + lines.push( + ` [${state}] ${result.probe.kind}${label} @(${result.probe.x},${result.probe.y})` + + ` — ${result.probe.possibility}; local diff cells=${result.near.cells}`, + ); + } + lines.push("No clicks, drags, or resizes were performed. Use an intentional action tool after choosing an affordance."); + return lines.join("\n"); +} + +async function toolHoverAtlas({ + machine, mode = "wanderer", x, y, radius = 18, steps, + settleMs = 140, ocr = true, fast = true, +} = {}) { + const spec = machineSpec(machine); + settleMs = Math.max(60, Math.min(600, Number(settleMs) || 140)); + const initial = await captureFrame({ + machine, ocr: false, fast: true, cursor: false, + baseline: true, quietOverlay: true, + }); + recentFrames.set(machine, initial.env); + const probes = buildHoverProbes(initial.env, { mode, x, y, radius, steps }); + const results = []; + for (const probe of probes) { + hoverPoint(spec, probe.x, probe.y); + await settle(settleMs); + const capture = await captureFrame({ + machine, ocr: false, fast: true, cursor: false, + diff: true, baseline: true, quietOverlay: true, + }); + results.push({ probe, near:changesNearPoint(capture.env, probe.x, probe.y), capture }); + } + + const ranked = [...results].sort((a, b) => + b.near.cells - a.near.cells || b.near.count - a.near.count); + const representative = ranked.find((result) => result.near.cells > 0) || ranked[0]; + const content = [{ type:"text", text:hoverAtlasText(mode, results) }]; + if (representative) { + hoverPoint(spec, representative.probe.x, representative.probe.y); + await settle(settleMs); + const bounds = captureBounds(initial.env); + const crop = clampCrop([ + representative.probe.x - 240, representative.probe.y - 180, 480, 360, + ], bounds); + const detail = await captureFrame({ + machine, ocr, fast, cursor: false, crop, quietOverlay: true, + }); + if (detail.jpg) content.unshift({ + type:"image", data:detail.jpg.toString("base64"), mimeType:"image/jpeg", + }); + content.push({ + type:"text", + text:`\nMOST INFORMATIVE PROBE — ${representative.probe.kind} @(${representative.probe.x},${representative.probe.y})\n${digest(detail.env)}`, + }); + } + const original = initial.env?.meta?.cursor; + if (Number.isFinite(original?.x) && Number.isFinite(original?.y)) { + hoverPoint(spec, original.x, original.y); + } + return content; +} + +async function toolWander(args = {}) { + return toolHoverAtlas({ ...args, mode:"wanderer" }); +} + +async function toolWiggle(args = {}) { + return toolHoverAtlas({ ...args, mode:"wiggler" }); +} + // Native exploration primitives return the post-action frame in the SAME MCP // response. Agents need one tool round-trip, not act → wait → call frame again. async function toolClick({ machine, x, y, count = 1, ocr = true, fast = true }) { @@ -1055,6 +1137,35 @@ const TOOLS = [ description: "OBSERVES CONTEXT: move the real pointer without clicking, wait for hover-only controls/tooltips, then return a cheaper cropped reframe around that point. Lesson 1: when an element may reveal options, hover and reframe before clicking. Coordinates remain global and click-ready.", inputSchema: { type: "object", properties: { machine: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number", description: "Crop width (default 720)." }, height: { type: "number", description: "Crop height (default 520)." }, ocr: { type: "boolean" }, fast: { type: "boolean" } }, required: ["machine", "x", "y"] }, }, + { + name: "frame_wander", + description: "WANDERER: without clicking, sweep likely buttons, hover controls, the titlebar, and focused-window resize edges/corners. Performs rapid mouse-move visual diffs and returns a hover-affordance atlas plus the most informative crop. Run after a frame when you need more context about what the current surface may allow.", + inputSchema: { + type:"object", + properties:{ + machine:{ type:"string" }, + steps:{ type:"number", minimum:1, maximum:24, description:"Maximum no-click probes (default 14)." }, + settleMs:{ type:"number", minimum:60, maximum:600, description:"Hover settle time per probe (default 140 ms)." }, + ocr:{ type:"boolean" }, fast:{ type:"boolean" }, + }, + required:["machine"], + }, + }, + { + name: "frame_wiggle", + description: "WIGGLER: without clicking, micro-sweep around one global screen coordinate to expose hover boundaries, cursor-shape transitions, nearby controls, or resize affordances. Uses quick visual diffs and returns an affordance atlas plus the strongest changed crop.", + inputSchema: { + type:"object", + properties:{ + machine:{ type:"string" }, x:{ type:"number" }, y:{ type:"number" }, + radius:{ type:"number", minimum:4, maximum:120, description:"Sweep radius in screen points (default 18)." }, + steps:{ type:"number", minimum:1, maximum:24, description:"Maximum no-click probes (default 9)." }, + settleMs:{ type:"number", minimum:60, maximum:600, description:"Hover settle time per probe (default 140 ms)." }, + ocr:{ type:"boolean" }, fast:{ type:"boolean" }, + }, + required:["machine", "x", "y"], + }, + }, { name: "frame_click", description: "ACTS + OBSERVES: click a native macOS screen coordinate from frame OCR/AX, then immediately return a fresh frame with a virtual marker at the click. Use for low-risk UI exploration; inspect labels and avoid destructive controls.", @@ -1156,6 +1267,8 @@ async function callTool(name, args) { case "frame_describe": return toolDescribe(args || {}); case "frame_design": return toolDesign(args || {}); case "frame_hover": return toolHover(args || {}); + case "frame_wander": return toolWander(args || {}); + case "frame_wiggle": return toolWiggle(args || {}); case "frame_click": return toolClick(args || {}); case "frame_stage_click": return toolStageClick(args || {}); case "frame_commit_click": return toolCommitClick(args || {}); diff --git a/slab/lib/frame-hover-atlas.mjs b/slab/lib/frame-hover-atlas.mjs new file mode 100644 index 000000000..9f7580007 --- /dev/null +++ b/slab/lib/frame-hover-atlas.mjs @@ -0,0 +1,104 @@ +// Pure geometry for Frame's no-click hover exploration. + +const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); + +function boundsOf(env) { + const crop = env?.crop; + if (crop && [crop.x, crop.y, crop.w, crop.h].every(Number.isFinite)) { + return [crop.x, crop.y, crop.w, crop.h]; + } + const screen = env?.meta?.screen; + if (screen && [screen.w, screen.h].every(Number.isFinite)) { + return [0, 0, screen.w, screen.h]; + } + throw new Error("Frame hover exploration needs capture bounds"); +} + +function inside([bx, by, bw, bh], x, y) { + return x >= bx && x <= bx + bw && y >= by && y <= by + bh; +} + +function unique(points, bounds, limit) { + const seen = new Set(); + const result = []; + for (const point of points) { + const x = Math.round(Number(point.x)); + const y = Math.round(Number(point.y)); + if (!Number.isFinite(x) || !Number.isFinite(y) || !inside(bounds, x, y)) continue; + const key = `${Math.round(x / 8)},${Math.round(y / 8)}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ ...point, x, y }); + if (result.length >= limit) break; + } + return result; +} + +export function buildHoverProbes(env, { + mode = "wanderer", x, y, radius = 18, steps = mode === "wiggler" ? 9 : 14, +} = {}) { + const bounds = boundsOf(env); + const limit = clamp(Math.round(Number(steps) || 1), 1, 24); + if (mode === "wiggler") { + x = Number(x); y = Number(y); radius = clamp(Number(radius) || 18, 4, 120); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + throw new Error("wiggler needs finite x and y coordinates"); + } + const ring = [ + [0, 0], [-1, 0], [1, 0], [0, -1], [0, 1], + [-0.72, -0.72], [0.72, -0.72], [0.72, 0.72], [-0.72, 0.72], + [-0.45, 0], [0.45, 0], [0, -0.45], [0, 0.45], + ]; + return unique(ring.map(([dx, dy], index) => ({ + x:x + dx * radius, y:y + dy * radius, + kind:index === 0 ? "probe-center" : "hover-boundary", + possibility:"hover boundary or cursor-shape change", + })), bounds, limit); + } + + if (mode !== "wanderer") throw new Error("mode must be wanderer or wiggler"); + const [bx, by, bw, bh] = bounds; + const inset = Math.max(2, Math.min(6, Math.round(Math.min(bw, bh) * 0.004))); + const points = []; + + // Semantic controls first, followed by image-discovered compact controls. + for (const element of env?.ax?.elements || []) { + const actions = element.actions || []; + if (!actions.length && !/button|link|menu|control/i.test(element.role || "")) continue; + points.push({ + x:element.cx, y:element.cy, kind:`ax-${element.role || "control"}`, + label:String(element.title || "").replace(/\s+/g, " ").trim().slice(0, 80), + possibility:actions.includes("AXPress") ? "button or pressable control" : "interactive control", + }); + } + // Focused-window geometry provides useful probes even when AX is sparse. + points.push( + { x:bx + bw / 2, y:by + Math.min(24, bh * 0.035), kind:"window-title", possibility:"drag window" }, + { x:bx + inset, y:by + bh / 2, kind:"window-left-edge", possibility:"resize window horizontally" }, + { x:bx + bw - inset, y:by + bh / 2, kind:"window-right-edge", possibility:"resize window horizontally" }, + { x:bx + bw / 2, y:by + bh - inset, kind:"window-bottom-edge", possibility:"resize window vertically" }, + { x:bx + inset, y:by + bh - inset, kind:"window-bottom-left-corner", possibility:"resize window diagonally" }, + { x:bx + bw - inset, y:by + bh - inset, kind:"window-bottom-right-corner", possibility:"resize window diagonally" }, + ); + for (const control of env?.visual || []) { + points.push({ + x:control.cx, y:control.cy, kind:control.kind || "visual-control", + possibility:"potential button or hover-only control", + }); + } + return unique(points, bounds, limit); +} + +export function changesNearPoint(env, x, y, radius = 150) { + const changes = (env?.diff || []).filter((change) => { + const rect = Array.isArray(change.r) ? change.r.map(Number) : null; + if (!rect || rect.length !== 4 || !rect.every(Number.isFinite)) return false; + const cx = rect[0] + rect[2] / 2; + const cy = rect[1] + rect[3] / 2; + return Math.hypot(cx - x, cy - y) <= radius; + }); + return { + count:changes.length, + cells:changes.reduce((sum, change) => sum + Math.max(1, Number(change.cells) || 1), 0), + }; +} diff --git a/slab/test/frame-hover-atlas.test.mjs b/slab/test/frame-hover-atlas.test.mjs new file mode 100644 index 000000000..31c0dd8bc --- /dev/null +++ b/slab/test/frame-hover-atlas.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildHoverProbes, changesNearPoint } from "../lib/frame-hover-atlas.mjs"; + +const env = { + crop:{ x:100, y:80, w:900, h:620 }, + ax:{ elements:[{ role:"AXButton", title:"Share", cx:850, cy:130, actions:["AXPress"] }] }, + visual:[{ kind:"compact-control", cx:180, cy:240 }], +}; + +test("wanderer combines controls with draggable and resizable window surfaces", () => { + const probes = buildHoverProbes(env, { mode:"wanderer", steps:20 }); + assert.ok(probes.some((probe) => probe.possibility === "button or pressable control")); + assert.ok(probes.some((probe) => probe.possibility === "drag window")); + assert.ok(probes.some((probe) => probe.possibility === "resize window diagonally")); + assert.ok(probes.every((probe) => probe.x >= 100 && probe.x <= 1000)); +}); + +test("wiggler makes a bounded no-click ring around the supplied point", () => { + const probes = buildHoverProbes(env, { mode:"wiggler", x:500, y:300, radius:20, steps:9 }); + assert.equal(probes.length, 9); + assert.deepEqual(probes[0], { + x:500, y:300, kind:"probe-center", + possibility:"hover boundary or cursor-shape change", + }); +}); + +test("quick diff scoring isolates changes near the moved pointer", () => { + const score = changesNearPoint({ diff:[ + { r:[490, 290, 20, 20], cells:8 }, + { r:[900, 600, 20, 20], cells:40 }, + ] }, 500, 300, 80); + assert.deepEqual(score, { count:1, cells:8 }); +}); -- 2.51.2