Repository navigation
feat(control): under an agent, calls that bring an app to the front or move the pointer are refused, and recordings leave out excluded windows #591
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e8e10aa
d227e96
f7926ee
1bb65ba
53c4da4
2e6e8d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import Foundation | ||
|
|
||
| /// The quiet time, in seconds, an agent waits for before it may bring an app to the front or move the | ||
| /// pointer. Less than this since the user's last key, click, move or scroll means the user is working, | ||
| /// and an agent that took focus then typed into their window (2026-10-23: "you're taking my focus quite | ||
| /// a lot during this testing"). | ||
| public let defaultForegroundIdleSeconds: TimeInterval = 5 | ||
|
|
||
| /// `GENESIS_CONTROL_FOREGROUND_GUARD=1` turns the check on. The TypeScript runner sets it when an agent | ||
| /// drives the tool; a person typing a command just pressed Return, and must not be refused for it. | ||
| public func foregroundGuardEnabled(_ environment: [String: String]) -> Bool { | ||
| environment["GENESIS_CONTROL_FOREGROUND_GUARD"]?.trimmingCharacters(in: .whitespaces) == "1" | ||
| } | ||
|
|
||
| /// `GENESIS_CONTROL_FOREGROUND_IDLE=<seconds>` sets the quiet time; 0 turns the check off. A value that | ||
| /// is not a number keeps the default: a guard that a typo disables is not a guard. | ||
| public func foregroundIdleThreshold(_ environment: [String: String]) -> TimeInterval { | ||
| guard let raw = environment["GENESIS_CONTROL_FOREGROUND_IDLE"]?.trimmingCharacters(in: .whitespaces), | ||
| let value = Double(raw), value.isFinite, value >= 0 else { | ||
| return defaultForegroundIdleSeconds | ||
| } | ||
|
|
||
| return value | ||
| } | ||
|
|
||
| /// `GENESIS_CONTROL_ALLOW_FOREGROUND=1` lets a whole run take focus: a demonstration the user asked for. | ||
| public func foregroundAllowedByEnvironment(_ environment: [String: String]) -> Bool { | ||
| guard let raw = environment["GENESIS_CONTROL_ALLOW_FOREGROUND"]?.trimmingCharacters(in: .whitespaces).lowercased() else { | ||
| return false | ||
| } | ||
|
|
||
| return ["1", "true", "yes", "on"].contains(raw) | ||
| } | ||
|
|
||
| /// `--allow-foreground` lifts the guard only as a flag its command parsed, never as the value of another option: | ||
| /// `act --text --allow-foreground` types those words and keeps the guard. `arguments` is the whole command line | ||
| /// (program, command, options). `act` answers from its own parser; `launch-app` reads its two value options; every | ||
| /// other command never gets the flag from the runner and never honours it. | ||
| public func foregroundAllowedByArguments(_ arguments: [String]) -> Bool { | ||
| guard arguments.count > 2 else { | ||
| return false | ||
| } | ||
|
|
||
| let command = arguments[1] | ||
| let options = Array(arguments.dropFirst(2)) | ||
| switch command { | ||
| case "act": | ||
| return (try? WorkflowArguments(options, command: command))?.flags.contains("--allow-foreground") == true | ||
| case "launch-app": | ||
| var index = 0 | ||
| while index < options.count { | ||
| if options[index] == "--bundle-id" || options[index] == "--path" { | ||
| index += 2 | ||
| continue | ||
| } | ||
|
|
||
| if options[index] == "--allow-foreground" { | ||
| return true | ||
| } | ||
|
|
||
| index += 1 | ||
| } | ||
|
|
||
| return false | ||
| default: | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| /// Any recent input keeps the guard closed. The window server's idle counter includes synthetic | ||
| /// posts, so subtracting our last post could hide a human event immediately before or after it. | ||
| /// Conservatively, synthetic input can extend the quiet time but can never shorten it. | ||
| public func userIsActive(idleSeconds: TimeInterval, threshold: TimeInterval) -> Bool { | ||
| threshold > 0 && idleSeconds.isFinite && idleSeconds >= 0 && idleSeconds < threshold | ||
| } | ||
|
|
||
| /// Admit a command's first global event independently of whether its app is already frontmost. | ||
| /// Later events in that command must not refuse on the idle counter its own first event reset. | ||
| public final class ForegroundInputGate { | ||
| private let check: () throws -> Void | ||
| private var admitted = false | ||
|
|
||
| public init(check: @escaping () throws -> Void) { | ||
| self.check = check | ||
| } | ||
|
|
||
| public func dispatch<T>(background: Bool, _ send: () throws -> T) throws -> T { | ||
| if !background && !admitted { | ||
| try check() | ||
| admitted = true | ||
| } | ||
| return try send() | ||
| } | ||
| } | ||
|
|
||
| /// The user is at the keyboard or mouse, so this call did not take focus. Never retried automatically. | ||
| public struct UserActiveError: Error, LocalizedError { | ||
| public let idleSeconds: TimeInterval | ||
| public let threshold: TimeInterval | ||
|
|
||
| public init(idleSeconds: TimeInterval, threshold: TimeInterval) { | ||
| self.idleSeconds = idleSeconds | ||
| self.threshold = threshold | ||
| } | ||
|
|
||
| public var category: SnapshotRefusal { .userActive } | ||
|
|
||
| public var errorDescription: String? { | ||
| "the user is working on this Mac (their last key, click or pointer move was " | ||
| + String(format: "%.1f", idleSeconds) + " s ago, under the " + String(format: "%g", threshold) | ||
| + " s quiet time), so no app was brought to the front and the pointer did not move. Act in the " | ||
| + "background instead: click without prepare (AXPress or a window-addressed click), press_key with " | ||
| + "activate:false, set_value or fill_form without prepare, launch_app with activate:false, open -g " | ||
| + "for a URL. Only for a visible demonstration the user asked for: allow_foreground:true " | ||
| + "(--allow-foreground, or GENESIS_CONTROL_ALLOW_FOREGROUND=1 for a whole run). " | ||
| + "GENESIS_CONTROL_FOREGROUND_IDLE=0 turns this check off" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,6 +72,10 @@ private func workflowFailure(_ message: String, category: SnapshotRefusal = .ref | |
| } | ||
|
|
||
| private func workflowFailure(_ error: Error, extras: [String: Any] = [:]) -> Never { | ||
| if let active = error as? UserActiveError { | ||
| workflowDispatchState = inputGate.posted > 0 ? "uncertain" : "not_started" | ||
| workflowFailure(active.localizedDescription, category: active.category, extras: extras) | ||
| } | ||
| if let takeover = error as? UserTakeoverError { | ||
| workflowDispatchState = takeover.dispatched ? "uncertain" : "not_started" | ||
| workflowFailure(takeover.localizedDescription, category: takeover.category, extras: extras) | ||
|
|
@@ -1135,6 +1139,11 @@ func cmdAct(appName _: String) { | |
| guard (axAttribute(element,"AXEnabled") as? NSNumber)?.boolValue != false else { | ||
| workflowFailure("selected target is disabled") | ||
| } | ||
| // Preparation raises the window and moves keyboard focus, also inside an app that is already in front. A | ||
| // prepared right-click does neither: it skips focus and raise and clicks in the background. | ||
| if !secondaryClick { | ||
| gatedOrExit { try checkForegroundAllowed() } | ||
| } | ||
| let before = tree.rows[elementIndex] | ||
| if !secondaryClick { | ||
| workflowFocus(window, pid:pid, element:window.ax) | ||
|
|
@@ -1657,7 +1666,8 @@ func cmdAct(appName _: String) { | |
| tree.rows.compactMap { $0["AXDescription"] as? String }) | ||
| ActionCursor.emit("move", point: point, background: false, target: rawCoords == nil ? "ax" : "pixel") | ||
| // Checked before the warp: once it runs, the pointer is ours and no longer says | ||
| // where the user put it. | ||
| // where the user put it. A hover takes the user's pointer, so it waits for them too. | ||
| try checkForegroundAllowed() | ||
| try inputGate.post { _ = CGWarpMouseCursorPosition(point) } | ||
| if let moved = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, | ||
| mouseCursorPosition: point, mouseButton: .left) { | ||
|
|
@@ -2077,6 +2087,14 @@ func cmdAct(appName _: String) { | |
| // why, rather than wondering whether it was silently skipped. | ||
| payload["nonActivatingPanel"] = true | ||
| } | ||
| // A window-addressed left click into an app that is not in front lands in a window that is not key. | ||
| // A SwiftUI control there takes it as the activation click and does nothing, while the click itself | ||
| // was delivered (a sheet's "Cancel" stayed open, 2026-10-23). Say so, so the caller checks the effect. | ||
| if action == "click", workflowFlag("--background"), !secondaryClick, !nonActivatingPanel, startingFrontmost != pid { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Quality | 🟡 Medium · confidence 85/100 Detect inactive windows within the frontmost app too The new effect note tests only whether another application was frontmost. When the observed target is a non-key window of the already-frontmost app, a background window-addressed click has the same activation-click limitation, but startingFrontmost equals pid and the response omits the warning. Multi-window operation is explicitly supported by the new window_id plumbing, so this misses an important supported case and callers receive plain ok:true for a potentially ineffective click. Base the note on the target window's actual key/focused-window status, not just the application's PID, and cover two windows in one app. 🧩 AnalysisGrep evidence:
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Accepted. The follow-up will detect inactive windows within the frontmost app too. The pull request was merged after its fix rounds, so this finding waits for a follow-up, and this thread stays open until then. |
||
| payload["inactiveWindowClick"] = true | ||
| payload["effectNote"] = "delivered to a window that is not key: a SwiftUI control may take it as the activation " | ||
| + "click and do nothing. Check the postcondition; AXPress (a click without physical) acts without focus" | ||
| } | ||
|
|
||
| payload.merge(actionExtras) { _, new in new } | ||
| if workflowFlag("--refresh") { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.