Skip to content

Commit 213fdf8

Browse files
iamnbutlerclaude
andauthored
feat: report which windows a menu command, launch or open created (#56)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4aa2609 commit 213fdf8

9 files changed

Lines changed: 76 additions & 7 deletions

File tree

‎dist/client.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,7 @@ function actionResult(data, outcome) {
661661
clipboard_cleanup: data.clipboard_cleanup,
662662
clipboard_ownership: data.clipboard_ownership,
663663
consumption: data.consumption,
664+
created_windows: data.created_windows,
664665
},
665666
warning: "Native action metadata exceeded the result limit. Inspect the current state.",
666667
});

‎dist/tools.js‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export function desktopTools({ place, app }) {
186186
{
187187
name: "desktop_launch",
188188
op: "launch",
189-
description: `Launch or activate one application on ${place} using an absolute .app path or exact bundle ID. This deliberately brings the app to the foreground and may switch Spaces. A bundle ID lets macOS choose the installation; use a path to select a particular copy. Returns the signed native process target and fresh inventory when available; a completed launch does not promise a visible or usable window. It does not open documents or URLs, create an extra instance, or relaunch. One native launch can include several counted activation attempts. Timeout or interruption is unknown: the app may still open later. Observe desktop_apps before any further action; never blindly repeat an interrupted launch.`,
189+
description: `Launch or activate one application on ${place} using an absolute .app path or exact bundle ID. This deliberately brings the app to the foreground and may switch Spaces. A bundle ID lets macOS choose the installation; use a path to select a particular copy. Returns the signed native process target and fresh inventory when available; a completed launch does not promise a visible or usable window. created_windows lists the app's windows that appeared after the request was sent, with targets for desktop_inspect: attribution exclusive means exactly one appeared, ambiguous means several did and nothing says which came from this request, so inspect them or ask before choosing; none means no window appeared in time. It does not open documents or URLs, create an extra instance, or relaunch. One native launch can include several counted activation attempts. Timeout or interruption is unknown: the app may still open later. Observe desktop_apps before any further action; never blindly repeat an interrupted launch.`,
190190
parameters: {
191191
type: "object",
192192
required: ["application"],
@@ -197,7 +197,7 @@ export function desktopTools({ place, app }) {
197197
{
198198
name: "desktop_open",
199199
op: "open",
200-
description: `Open one item on ${place}: an existing absolute path to a file, folder or app bundle, or a complete absolute URL with an explicit scheme. Item strings are limited to 4096 UTF-16 units and the complete request to 16 KiB. Paths preserve Unicode and spaces without shell expansion; URLs must already be correctly encoded and are never repaired. Optional application selects an absolute .app path or exact bundle ID, with the same meaning as desktop_launch; omitted application lets macOS choose the default handler. This deliberately brings the receiving app forward and may switch Spaces. Any URL scheme, including file and custom schemes, has its receiving app's normal effects. A file URL with a query or fragment is refused because the file opens as a document and they would be dropped; serve it over http to keep them. Completed means macOS accepted delivery, not that a document or page loaded. Returns the signed receiving process target and fresh inventory when available, without choosing a window. No extra instance, relaunch, batch, or automatic dialog handling. Observe the receiving app after delivery or uncertainty; never blindly repeat an interrupted open.`,
200+
description: `Open one item on ${place}: an existing absolute path to a file, folder or app bundle, or a complete absolute URL with an explicit scheme. Item strings are limited to 4096 UTF-16 units and the complete request to 16 KiB. Paths preserve Unicode and spaces without shell expansion; URLs must already be correctly encoded and are never repaired. Optional application selects an absolute .app path or exact bundle ID, with the same meaning as desktop_launch; omitted application lets macOS choose the default handler. This deliberately brings the receiving app forward and may switch Spaces. Any URL scheme, including file and custom schemes, has its receiving app's normal effects. A file URL with a query or fragment is refused because the file opens as a document and they would be dropped; serve it over http to keep them. Completed means macOS accepted delivery, not that a document or page loaded. Returns the signed receiving process target and fresh inventory when available, without choosing a window. created_windows lists the app's windows that appeared after the request was sent, with targets for desktop_inspect: attribution exclusive means exactly one appeared, ambiguous means several did and nothing says which came from this request, so inspect them or ask before choosing; none means no window appeared in time. No extra instance, relaunch, batch, or automatic dialog handling. Observe the receiving app after delivery or uncertainty; never blindly repeat an interrupted open.`,
201201
parameters: {
202202
type: "object",
203203
required: ["item"],
@@ -251,7 +251,7 @@ export function desktopTools({ place, app }) {
251251
{
252252
name: "desktop_menu",
253253
op: "menu",
254-
description: `Invoke one menu command in an exact external application target from desktop_apps. Commands targeting this native ${app} process itself are unsupported and refused before input. Pass a literal title array discovered with desktop_menus; titles must match exactly, including punctuation such as a trailing ellipsis ('Save…'), whitespace and Unicode. A missing title is refused with the titles found at that level. The native service resolves the path afresh, refuses missing/ambiguous/disabled or unavailable lazy paths, and presses only the final item once; it makes no separate activation request and does not open ancestor menus. The app or macOS may still bring the app forward in response. A completed result proves accepted AX delivery, not that the command finished. Modal commands can return unknown while a dialog remains open. Read desktop_windows or inspect the current state before deciding what to do next; never blindly repeat the command. Blocking AX reads and AXPress run off the GUI actor, with a finite native messaging timeout; cancellation retains native ownership until the actual call returns and cannot undo delivered input. No screenshot or input snapshot is returned.`,
254+
description: `Invoke one menu command in an exact external application target from desktop_apps. Commands targeting this native ${app} process itself are unsupported and refused before input. Pass a literal title array discovered with desktop_menus; titles must match exactly, including punctuation such as a trailing ellipsis ('Save…'), whitespace and Unicode. A missing title is refused with the titles found at that level. The native service resolves the path afresh, refuses missing/ambiguous/disabled or unavailable lazy paths, and presses only the final item once; it makes no separate activation request and does not open ancestor menus. The app or macOS may still bring the app forward in response. A completed result proves accepted AX delivery, not that the command finished. Modal commands can return unknown while a dialog remains open. created_windows lists the app's windows that appeared after the request was sent, with targets for desktop_inspect: attribution exclusive means exactly one appeared, ambiguous means several did and nothing says which came from this request, so inspect them or ask before choosing; none means no window appeared in time. Read desktop_windows or inspect the current state before deciding what to do next; never blindly repeat the command. Blocking AX reads and AXPress run off the GUI actor, with a finite native messaging timeout; cancellation retains native ownership until the actual call returns and cannot undo delivered input. No screenshot or input snapshot is returned.`,
255255
parameters: {
256256
type: "object",
257257
required: ["target", "path"],

‎docs/tools.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ Targets are exact: an app target binds a process generation, and a window target
3333

3434
Window operations other than focus use background Accessibility and don't activate the app. `desktop_unhide` activates the app, because macOS has no exact background unhide.
3535

36+
`desktop_launch`, `desktop_open` and `desktop_menu` report `created_windows`: the app's windows that weren't there just before the request was sent, each with its target for `desktop_inspect`. They wait up to about one second for a menu command and two for a launch or open. `attribution` is `exclusive` when exactly one window appeared, `ambiguous` when several did, and `none` when none did in time. When two agents work in the same app at once, `exclusive` is very likely but not certain to be the agent's own window, since the other agent's window can appear in the same interval; `ambiguous` means nothing tells them apart.
37+
3638
## Menu bar and Dock
3739

3840
| Tool | Does |

‎native/sources/client/client.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ private struct Windows: Encodable {
302302
let inventory_warnings: [String]
303303
}
304304

305-
private struct Window: Encodable {
305+
struct Window: Encodable {
306306
let window_id: Int
307307
let window_title: String
308308
let bounds: Bounds
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import CoreGraphics
2+
import Foundation
3+
import PeekabooAutomationKit
4+
import PeekabooBridge
5+
import PeekabooFoundation
6+
7+
/// Windows of the receiving process that appeared after an action was dispatched.
8+
struct CreatedWindows: Encodable {
9+
let windows: [Window]
10+
/// exclusive when exactly one window appeared, ambiguous when several did, none when no window appeared.
11+
let attribution: String
12+
let note: String
13+
}
14+
15+
/// Every window ID WindowServer knows, on screen or not; taken just before dispatch.
16+
func existingWindowIDs() -> Set<Int> {
17+
let list = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] ?? []
18+
return Set(list.compactMap { ($0[kCGWindowNumber as String] as? NSNumber)?.intValue })
19+
}
20+
21+
/// Waits briefly for a new normal window of the process, then lists its new inventory windows. Never sends input.
22+
func createdWindows(
23+
_ client: PeekabooBridgeClient, process: ApplicationProcessIdentity, before: Set<Int>, waitingUpTo seconds: Double
24+
) async -> CreatedWindows? {
25+
let deadline = Date().addingTimeInterval(seconds)
26+
repeat {
27+
let list = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] ?? []
28+
let appeared = list.contains { info in
29+
(info[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == process.processIdentifier
30+
&& (info[kCGWindowLayer as String] as? NSNumber)?.intValue == 0
31+
&& !before.contains((info[kCGWindowNumber as String] as? NSNumber)?.intValue ?? 0)
32+
}
33+
if appeared {
34+
// Windows open in quick succession, as with several documents; let them settle.
35+
try? await Task.sleep(nanoseconds: 250_000_000)
36+
break
37+
}
38+
try? await Task.sleep(nanoseconds: 125_000_000)
39+
} while Date() < deadline
40+
guard let inventory = try? await client.listWindowMutationInventory(
41+
target: .application("PID:\(process.processIdentifier)")
42+
) else { return nil }
43+
let windows = inventory.items.filter { item in
44+
!before.contains(item.windowID) && item.mutationIdentity?.processIdentity == process
45+
}.map { Window($0, pid: process.processIdentifier) }
46+
switch windows.count {
47+
case 0:
48+
return CreatedWindows(windows: [], attribution: "none", note: "No new window of the app appeared within about \(Int(seconds.rounded())) second\(seconds < 1.5 ? "" : "s"). One may still open later, or the action may reuse an existing window; list desktop_windows before acting.")
49+
case 1:
50+
return CreatedWindows(windows: windows, attribution: "exclusive", note: "This is the only window of the app that appeared after the action was sent, so it is very likely the one the action opened. Another agent acting on the same app at the same moment can still open a window in this interval; if that matters, confirm its contents with desktop_inspect.")
51+
default:
52+
return CreatedWindows(windows: windows, attribution: "ambiguous", note: "Several windows of the app appeared after the action was sent. The action may have opened more than one, or something else opened the others at the same time; nothing here says which came from this action. Inspect them, or ask the user, before choosing one.")
53+
}
54+
}

‎native/sources/client/launch.swift‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ private struct LaunchResult: Encodable {
2929
var native_outcome: DesktopActionOutcome?
3030
var application: LaunchedApplication?
3131
var requires_fresh_observation = false
32+
var created_windows: CreatedWindows?
3233
var message: String?
3334
var error: LaunchMessage?
3435
}
@@ -65,6 +66,7 @@ func nativeLaunch(_ client: PeekabooBridgeClient, handshake: PeekabooBridgeHands
6566
throw LaunchError("The native runtime cannot attest application targets. Update the desktop runtime before acting.")
6667
}
6768
try Task.checkCancellation()
69+
let before = existingWindowIDs()
6870
invoked = true
6971
let action = try await client.launchApplicationResult(request: request)
7072
result.native_outcome = action.outcome
@@ -91,6 +93,10 @@ func nativeLaunch(_ client: PeekabooBridgeClient, handshake: PeekabooBridgeHands
9193
default: result.outcome = "unknown"
9294
}
9395
}
96+
if outcome.dispatchState.mutationDispatched {
97+
// Launch waits for the app, not a window; a slow app's window can arrive after this.
98+
result.created_windows = await createdWindows(client, process: process, before: before, waitingUpTo: 2)
99+
}
94100
} catch let failure as DesktopActionFailure {
95101
let dispatched = failure.outcome.dispatchState.mutationDispatched
96102
result.outcome = dispatched ? "unknown" : "refused"

‎native/sources/client/menu.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ private struct MenuCommandResult: Encodable {
2626
let action = "menu"
2727
var native_outcome: DesktopActionOutcome?
2828
var requires_fresh_observation = false
29+
var created_windows: CreatedWindows?
2930
var error: MenuCommandMessage?
3031
}
3132

@@ -69,6 +70,7 @@ func nativeMenuCommand(_ client: PeekabooBridgeClient, handshake: PeekabooBridge
6970
inventory.items.contains(where: { $0.processIdentity == identity })
7071
else { throw MenuCommandError("The observed application generation could not be attested before the menu command.") }
7172
try Task.checkCancellation()
73+
let before = existingWindowIDs()
7274
invoked = true
7375
let action = try await client.menuCommand(request)
7476
result.native_outcome = action.outcome
@@ -87,6 +89,9 @@ func nativeMenuCommand(_ client: PeekabooBridgeClient, handshake: PeekabooBridge
8789
default:
8890
result.outcome = "unknown"
8991
}
92+
if outcome.dispatchState.mutationDispatched {
93+
result.created_windows = await createdWindows(client, process: identity, before: before, waitingUpTo: 1)
94+
}
9095
} catch let failure as DesktopActionFailure {
9196
result.native_outcome = failure.outcome
9297
result.outcome = failure.outcome.dispatchState.mutationDispatched ? "unknown" : "refused"

‎src/client.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,7 @@ function actionResult(data: Record<string, unknown>, outcome: DesktopOutcome): D
814814
clipboard_cleanup: data.clipboard_cleanup,
815815
clipboard_ownership: data.clipboard_ownership,
816816
consumption: data.consumption,
817+
created_windows: data.created_windows,
817818
},
818819
warning: "Native action metadata exceeded the result limit. Inspect the current state.",
819820
});

0 commit comments

Comments
 (0)