Skip to content

Commit bc7f20e

Browse files
authored
Merge pull request #132 from githubnext/codex/desktop-quit
feat(desktop): add exact-target normal application quit
2 parents 39a17ad + 086c9c4 commit bc7f20e

10 files changed

Lines changed: 131 additions & 24 deletions

File tree

‎apps/desktop/native/patches/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ Unresolved delivery blocks later automated clipboard writes until a live read co
5353
edit or that exact process generation ends. Only reservation metadata survives a GUI restart;
5454
no clipboard contents, hashes, or deferred restoration are persisted.
5555

56+
`peekaboo-quit.patch` preserves accepted but unfinished normal quit in
57+
[native computer use](https://github.com/githubnext/ace2/issues/8).
58+
A normal quit can leave an application waiting for an unsaved-work decision. That is one
59+
dispatched operation with unverified completion and unsafe retry, not a safely repeatable no-op.
60+
The quit-specific result validator permits this canonical outcome with `false` termination so
61+
the existing bridge returns the boolean and its signed process receipt. Other false/success
62+
contradictions stay errors. Force-quit behavior, target revalidation, and mutation lanes are unchanged.
63+
5664
The desktop build resolves only `Package.resolved` versions, checks the pin and checkout revision,
5765
and applies each patch before compiling Swift. A repeated build accepts each only when its reverse
5866
patch check succeeds. If neither direction applies, the build fails with Git's diagnostics rather
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Core/Protocols/ApplicationServiceProtocol.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Core/Protocols/ApplicationServiceProtocol.swift
2+
index a1c9852e9..a72ab102f 100644
3+
--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Core/Protocols/ApplicationServiceProtocol.swift
4+
+++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Core/Protocols/ApplicationServiceProtocol.swift
5+
@@ -242,6 +242,12 @@ public enum ApplicationActionResultSemantics {
6+
hint: "Observe the pinned application before retrying and update the runtime host.")
7+
.attributed(to: expectedIdentity.actionTargetReceipt)
8+
}
9+
+ // The bool records termination, not whether the quit request was accepted.
10+
+ if !result.payload, outcome.state == .dispatchedUnverified,
11+
+ outcome.evidence == .operationStillRunning
12+
+ {
13+
+ return
14+
+ }
15+
if !result.payload, outcome.isAccepted(by: .confirmedOrDispatched) {
16+
throw DesktopActionFailure.indeterminate(
17+
route: outcome.route,
18+
diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ApplicationService+ActionOutcomes.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ApplicationService+ActionOutcomes.swift
19+
index de8b7d9b2..448f67d7e 100644
20+
--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ApplicationService+ActionOutcomes.swift
21+
+++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ApplicationService+ActionOutcomes.swift
22+
@@ -101,9 +101,16 @@ extension ApplicationService {
23+
hint: "Refresh the application inventory before retrying.")
24+
}
25+
let delivery = Self.applicationDelivery(mode: .background)
26+
- let outcome: DesktopActionOutcome = attempt.terminated
27+
- ? .confirmedChange(delivery: delivery, unitCount: .one)
28+
- : .suspectedNoop(delivery: delivery, unitCount: .one)
29+
+ let outcome: DesktopActionOutcome
30+
+ if !attempt.terminated, !request.force {
31+
+ // An accepted quit can be waiting on unsaved work; repeating it is not safe.
32+
+ outcome = .dispatchedUnverified(
33+
+ delivery: delivery, evidence: .operationStillRunning, unitCount: .one)
34+
+ } else {
35+
+ outcome = attempt.terminated
36+
+ ? .confirmedChange(delivery: delivery, unitCount: .one)
37+
+ : .suspectedNoop(delivery: delivery, unitCount: .one)
38+
+ }
39+
return DesktopActionResult(payload: attempt.terminated, outcome: outcome)
40+
}
41+
} catch let failure as DesktopActionFailure {

‎apps/desktop/native/sources/client/client.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ private enum Client {
3636
requestTimeoutSec: 25,
3737
trustedHostTeamIDs: [identity.team]
3838
)
39-
try await client.handshake(
39+
let handshake = try await client.handshake(
4040
client: .init(
4141
bundleIdentifier: identity.identifier,
4242
teamIdentifier: identity.team,
@@ -49,7 +49,7 @@ private enum Client {
4949
case "action":
5050
return try await nativeAction(client)
5151
case "management":
52-
return try await nativeManagement(client)
52+
return try await nativeManagement(client, handshake: handshake)
5353
case "apps":
5454
let inventory = try await client.listApplicationMutationInventory()
5555
var metadata: [ServiceApplicationInfo] = []

‎apps/desktop/native/sources/client/management.swift‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ struct ManagementTarget: Codable {
5858

5959
private struct ManagementRequest: Decodable {
6060
enum Operation: String, Decodable {
61-
case activate, focus, restore, move, resize
61+
case activate, quit, focus, restore, move, resize
6262
}
6363
struct Position: Decodable {
6464
let x: Double
@@ -107,6 +107,8 @@ private struct ManagementResult: Encodable {
107107
var outcome = "refused"
108108
var action = "management"
109109
var native_outcome: DesktopActionOutcome?
110+
var terminated: Bool?
111+
var message: String?
110112
var requires_fresh_observation = false
111113
var error: ManagementMessage?
112114
}
@@ -117,7 +119,7 @@ private struct ManagementReply: Encodable {
117119
let target_receipt: Receipt?
118120
}
119121

120-
func nativeManagement(_ client: PeekabooBridgeClient) async throws -> Data {
122+
func nativeManagement(_ client: PeekabooBridgeClient, handshake: PeekabooBridgeHandshakeResponse) async throws -> Data {
121123
var result = ManagementResult()
122124
var receipt: Receipt?
123125
var invoked = false
@@ -127,14 +129,20 @@ func nativeManagement(_ client: PeekabooBridgeClient) async throws -> Data {
127129
try request.validate()
128130
let process = try request.target.processIdentity()
129131
let window: WindowMutationIdentity?
130-
if request.op == .activate {
132+
if request.op == .activate || request.op == .quit {
131133
guard request.target.window_id == nil, request.target.bounds == nil, request.target.is_minimized == nil else {
132-
throw ManagementError("Activation takes an application target from desktop_apps.")
134+
throw ManagementError("Activation and quit take an application target from desktop_apps.")
133135
}
134136
window = nil
135137
} else {
136138
window = try request.target.windowIdentity()
137139
}
140+
if request.op == .quit {
141+
guard handshake.negotiatedVersion >= PeekabooBridgeConstants.processGenerationPinnedApplicationQuitVersion,
142+
handshake.supportedOperations.contains(.quitApplication),
143+
(handshake.enabledOperations ?? handshake.supportedOperations).contains(.quitApplication)
144+
else { throw ManagementError("The desktop runtime does not support process-generation-pinned quit. Update the runtime before acting.") }
145+
}
138146
// Match Peekaboo's capture preflight; absent session state does not establish a lock.
139147
let session = CGSessionCopyCurrentDictionary() as NSDictionary?
140148
if session?["CGSSessionScreenIsLocked"] as? Bool == true {
@@ -156,6 +164,7 @@ func nativeManagement(_ client: PeekabooBridgeClient) async throws -> Data {
156164
try Task.checkCancellation()
157165
let outcome: DesktopActionOutcome?
158166
let operation: PeekabooBridgeOperation
167+
var terminated: Bool?
159168
invoked = true
160169
switch request.op {
161170
case .activate:
@@ -168,6 +177,13 @@ func nativeManagement(_ client: PeekabooBridgeClient) async throws -> Data {
168177
throw ManagementError("Activation returned no matching application-only receipt.")
169178
}
170179
outcome = action.outcome
180+
case .quit:
181+
operation = .quitApplication
182+
let action = try await client.quitApplicationResult(request: .init(
183+
identifier: "PID:\(process.processIdentifier)", force: false, expectedIdentity: process
184+
), supportsPinnedQuit: true)
185+
terminated = action.payload
186+
outcome = action.outcome
171187
case .focus:
172188
operation = .focusWindow
173189
let action = try await client.focusWindowResult(target: .windowId(window!.windowID), expectedIdentity: window!)
@@ -208,13 +224,17 @@ func nativeManagement(_ client: PeekabooBridgeClient) async throws -> Data {
208224
throw ManagementError("The management action's signed target did not match the original inventory target.")
209225
}
210226
result.requires_fresh_observation = outcome.dispatchState.mutationDispatched
227+
result.terminated = terminated
228+
if terminated == false {
229+
result.message = "The normal quit request was accepted, but termination was not confirmed. Inspect remaining windows for unsaved work or other dialogs; do not blindly retry or force quit."
230+
}
211231
switch outcome.state {
212232
case .refused:
213233
result.outcome = "refused"
214234
case .indeterminate, .partial:
215235
result.outcome = "unknown"
216236
default:
217-
result.outcome = outcome.evidence == .operationStillRunning ? "unknown" : "completed"
237+
result.outcome = outcome.evidence == .operationStillRunning || terminated == false ? "unknown" : "completed"
218238
}
219239
} catch let failure as DesktopActionFailure {
220240
result.outcome = failure.outcome.dispatchState.mutationDispatched ? "unknown" : "refused"

‎apps/desktop/native/sources/desktop/desktop.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ private final class Desktop {
7171
.ownsSnapshot, .getDetectionResult, .beginSnapshotMutation, .finishSnapshotMutation,
7272
.targetedClick, .exactWindowTargetedClick, .setValue, .exactWindowTargetedHotkey,
7373
.selectText, .literalInsert, .targetedScroll, .exactWindowDrag,
74-
.activateApplication, .focusWindow, .restoreWindow,
74+
.activateApplication, .quitApplication, .focusWindow, .restoreWindow,
7575
.moveWindow, .resizeWindow,
7676
],
7777
hostKind: .gui,

‎apps/desktop/scripts/helper.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,12 @@ if (!head.success || head.stdout.toString().trim() !== revision) {
141141
}
142142
// Keep local dependency fixes bound to the reviewed pin and verify every patch independently.
143143
for (
144-
const name of ["peekaboo-click.patch", "peekaboo-insert.patch", "peekaboo-pointer-window.patch"]
144+
const name of [
145+
"peekaboo-click.patch",
146+
"peekaboo-insert.patch",
147+
"peekaboo-pointer-window.patch",
148+
"peekaboo-quit.patch",
149+
]
145150
) {
146151
const patch = join(native, "patches", name);
147152
const forward = Bun.spawnSync([...git, "apply", "--check", patch]);

‎apps/host/src/desktop.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -410,11 +410,11 @@ function validateManagement(request: DesktopManagement) {
410410
|| !/^[1-9][0-9]{0,19}$/.test(target.process_start_identity_decimal)
411411
|| BigInt(target.process_start_identity_decimal) > 18_446_744_073_709_551_615n
412412
) throw new Error("Pass the application's target object from fresh desktop inventory unchanged.");
413-
if (request.op === "activate") {
413+
if (request.op === "activate" || request.op === "quit") {
414414
if (
415415
Object.keys(target).some((key) => !["pid", "process_start_identity_decimal"].includes(key))
416416
) {
417-
throw new Error("Activation takes an application target from desktop_apps.");
417+
throw new Error("Activation and quit take an application target from desktop_apps.");
418418
}
419419
return;
420420
}
@@ -457,6 +457,7 @@ function actionResult(data: Record<string, unknown>, outcome: DesktopOutcome): D
457457
action: {
458458
outcome,
459459
target_receipt: data.target_receipt,
460+
terminated: data.terminated,
460461
clipboard_changed: data.clipboard_changed,
461462
clipboard_cleanup: data.clipboard_cleanup,
462463
clipboard_ownership: data.clipboard_ownership,
@@ -493,6 +494,9 @@ async function act(request: DesktopAction, signal: AbortSignal): Promise<Desktop
493494
}, outcome);
494495
}
495496
const outcome = data.outcome as DesktopOutcome;
497+
if (request.op === "quit" && outcome === "unknown" && data.terminated === false) {
498+
return await observeManagement(request, data, signal);
499+
}
496500
if (outcome !== "completed") return actionResult(data, outcome);
497501
if (isDesktopManagement(request)) return await observeManagement(request, data, signal);
498502
// Observation is separate from delivery: its failure must not turn completed input into a retry.
@@ -541,11 +545,12 @@ async function observeManagement(
541545
signal: AbortSignal,
542546
): Promise<DesktopResult> {
543547
const data: Record<string, unknown> = { action };
548+
const outcome = request.op === "quit" && action.outcome === "unknown" ? "unknown" : "completed";
544549
const receipt = action.target_receipt as Reply["target_receipt"];
545550
if (
546551
!receipt || receipt.pid !== request.target.pid
547552
|| receipt.process_start_identity_decimal !== request.target.process_start_identity_decimal
548-
|| (request.op === "activate"
553+
|| (request.op === "activate" || request.op === "quit"
549554
? receipt.window_id !== undefined
550555
: receipt.window_id !== request.target.window_id)
551556
) {
@@ -563,6 +568,8 @@ async function observeManagement(
563568
data.application_inventory_warnings = apps.inventory_warnings;
564569
const app = apps.apps.find((app) => app.pid === receipt.pid);
565570
data.application = app || null;
571+
// Quit's native receipt owns termination evidence; later inventory cannot undo or establish it.
572+
if (!app && request.op === "quit") return managementResult(data, action, outcome);
566573
if (!app) throw new Error("The target application was not returned by the later inventory.");
567574
if (app.process_start_identity_decimal !== receipt.process_start_identity_decimal) {
568575
throw new Error("The application changed process generation after the action.");
@@ -581,8 +588,8 @@ async function observeManagement(
581588
"Later window inventory could not be bound to the original application generation.",
582589
);
583590
}
584-
if (request.op === "activate") {
585-
return managementResult(data, action);
591+
if (request.op === "activate" || request.op === "quit") {
592+
return managementResult(data, action, outcome);
586593
}
587594
if (!windows.windows.some((window) => window.window_id === receipt.window_id)) {
588595
throw new Error("The exact window was not returned by the later inventory.");
@@ -614,29 +621,31 @@ async function observeManagement(
614621
0,
615622
2000,
616623
);
617-
data.message =
618-
"The native action completed. Later inventory or inspection was unavailable; refresh the target before any further action, without repeating the completed action blindly.";
619-
return managementResult(data, action);
624+
data.message = outcome === "completed"
625+
? "The native action completed. Later inventory or inspection was unavailable; refresh the target before any further action, without repeating the completed action blindly."
626+
: "Termination was not confirmed and later inventory was unavailable. Refresh the target before choosing any further action; do not blindly retry or force quit.";
627+
return managementResult(data, action, outcome);
620628
}
621629
}
622630

623631
function managementResult(
624632
data: Record<string, unknown>,
625633
action: Record<string, unknown>,
634+
outcome: "completed" | "unknown" = "completed",
626635
): DesktopResult {
627636
try {
628637
const text = Array.isArray(data.windows) ? bounded(data, "windows") : JSON.stringify(data);
629638
if (Buffer.byteLength(text) > MAX_TEXT) {
630639
throw new Error("The later inventory exceeds the result limit.");
631640
}
632-
return { text, outcome: "completed", isError: false };
641+
return { text, outcome, isError: outcome !== "completed" };
633642
} catch {
634643
return actionResult({
635644
...action,
636645
observation_error: data.observation_error || "The later inventory exceeds the result limit.",
637646
message:
638-
"The native action completed. Later inventory was omitted to fit the result limit; refresh the target without blindly repeating the action.",
639-
}, "completed");
647+
"The native action outcome is preserved. Later inventory was omitted to fit the result limit; refresh the target without blindly repeating the action.",
648+
}, outcome);
640649
}
641650
}
642651

@@ -655,6 +664,7 @@ export const desktop: Desktop = async (request, context) => {
655664
"scroll",
656665
"drag",
657666
"activate",
667+
"quit",
658668
"focus",
659669
"restore",
660670
"move",

‎docs/architecture.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,12 @@ signing team. Tools expose application and window inventories, observation, elem
198198
clicks, scrolling, atomic drags, replacement of editable field values, text selection/insertion, and keys or shortcuts. Screenshot
199199
points are normalized and mapped through the snapshot's native capture geometry. Control input uses background delivery bound to a
200200
snapshot's exact process, window, and controls; every dispatched action requires a new observation.
201-
Separate activation, focus, restore, move, and resize tools accept generation-bound inventory targets, with exact
201+
Separate activation, quit, focus, restore, move, and resize tools accept generation-bound inventory targets, with exact
202202
window bounds where applicable. The native service revalidates those receipts before dispatch;
203203
activation and focus explicitly change the foreground desktop. Inspection never activates a target
204204
implicitly. App-only action receipts refresh inventories without inventing a selected window.
205+
Normal quit reports completion only after native termination confirmation; an accepted request that
206+
leaves the app running stays uncertain. It never retries, force-quits, or chooses a dialog response.
205207
Move and resize use native background Accessibility, verify resulting geometry, and return refreshed
206208
window targets; the original bounds remain part of the pre-mutation identity check.
207209
Literal insertion uses one GUI-owned temporary plain-text paste, with the existing native mutation

‎docs/desktop-tools.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Native desktop tools
22

3-
Ace's pi harness can inspect applications and windows, activate apps, focus, restore, move or resize windows, click observed controls, replace editable
3+
Ace's pi harness can inspect applications and windows, activate or quit apps, focus, restore, move or resize windows, click observed controls, replace editable
44
field values, select and insert text, send keys or shortcuts, scroll, and drag on the machine running the channel's tools. It uses
55
[Peekaboo](https://github.com/openclaw/Peekaboo) for macOS Accessibility, screen capture, and
66
targeted input. The model receives the accessibility text, screenshot, and action outcome. The
@@ -48,6 +48,13 @@ find the client alongside Ace Helper automatically.
4848
- `desktop_windows` lists windows for an application process ID.
4949
- `desktop_activate` brings a running application to the foreground using its `target` from
5050
`desktop_apps`. It can change the visible Space; it does not launch an app or select a window.
51+
- `desktop_quit` requests normal quit of one running application using its exact `target` from
52+
`desktop_apps`. It never retries, force-quits, or answers a dialog. A completed result means the
53+
native service confirmed termination. An accepted quit whose app remains running, for example
54+
for unsaved work, returns `unknown` with fresh application/window inventory when available.
55+
Its native outcome reports one dispatched operation still running and marks retry unsafe.
56+
Inspect a selected window and resolve any dialog deliberately before choosing another action.
57+
A later inventory failure or missing app does not change the original quit outcome.
5158
- `desktop_focus` brings one exact window to the foreground using its `target` from
5259
`desktop_windows`, activating its app and switching Spaces when needed.
5360
- `desktop_restore` unminimizes one exact window using its inventory `target` and background
@@ -115,7 +122,7 @@ matching rows omitted from the filtered response; narrow the query if needed. Se
115122
change native inventory completeness or warnings, and an empty match is not proof an app stopped.
116123

117124
Choose the application from the inventory and the window from that application's window list.
118-
Activation, focus, restore, move, and resize use inventory targets directly, so a failed inspection does not
125+
Activation, quit, focus, restore, move, and resize use inventory targets directly, so a failed inspection does not
119126
prevent explicit recovery. Pass the target object unchanged: it includes the process generation
120127
as a decimal string and, for windows, the original window ID, bounds, and minimized state. The
121128
native service revalidates that identity immediately before acting. These targets are not

0 commit comments

Comments
 (0)