Repository navigation
feat(macos): claim one app per native macOS app session #3323
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
base: main
Are you sure you want to change the base?
Changes from all commits
3796deb
7ec9b88
d6a8793
c269880
0586e88
306a727
873fa8e
1f09f25
88b9ead
f706256
ba03eda
8974ee3
fd97751
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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -189,8 +189,9 @@ app for every request but `open` and the `batch` envelope, so a | |||||
| request naming no session cannot fall back to the host Mac. `open` requires the native app backend (ADR 0031), because XCTest | ||||||
| posts screen events that can land outside the app's window. A pid-pinned lease is checked against the | ||||||
| running process before each admitted request. A session opened under the lease holds its app, not | ||||||
| the Mac: it takes no host device claim, and other app-leased sessions on the same Mac do not conflict | ||||||
| with it, so one daemon serves several leased apps beside the host's own sessions. | ||||||
| the Mac: it takes no host device claim, and sessions on other apps of the same Mac, leased or | ||||||
| holding an app claim (ADR 0034), do not conflict with it, so one daemon serves several leased apps | ||||||
| beside the host's own sessions. A session on the same app does. | ||||||
|
Contributor
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. P2: This overstates same-app exclusion across daemons: a Prompt for AI agents
Suggested change
|
||||||
|
|
||||||
| These rules bind a request that names the lease or runs in its session. A daemon policy | ||||||
| `leases.require` (ADR 0029) refuses requests that name no lease. The proxy token is one credential | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -36,6 +36,8 @@ window fields are unmeasured. | |||||
| assistive client. The app surface walks up to 48 levels deep and reports a deeper tree as | ||||||
| truncated; other helper surfaces keep 12. | ||||||
| 6. Screenshots capture the session app's front window by itself through ScreenCaptureKit. | ||||||
| 7. An app session claims only its app, and `open` launches or reopens that app with `open -g`, | ||||||
|
Contributor
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. P2: This rule promises per-app claims and background Prompt for AI agents
Suggested change
|
||||||
| leaving the frontmost app in front ([ADR 0034](0034-macos-per-app-claims.md)). | ||||||
|
|
||||||
| ## Context | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,83 @@ | ||||||
| # ADR 0034: macOS Per-App Device Claims | ||||||
|
|
||||||
| ## Status | ||||||
|
|
||||||
| Accepted (2026-10-08). Applies to the native macOS app backend (ADR 0031) only. Numbered 0034 | ||||||
|
Contributor
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. P3: The numbering note is process history, which the ADR README says belongs in git history. Drop the 'Numbered 0034 because 0032 and 0033 exist on main.' sentence from Status. Prompt for AI agents
Suggested change
|
||||||
| because 0032 and 0033 exist on main. | ||||||
|
|
||||||
| ## Rules at a glance | ||||||
|
|
||||||
| 1. A native macOS `app` session whose app resolves to a bundle id before launch claims | ||||||
| `<device key>:app:<bundleId>` instead of the device key. Every other macOS session (XCTest, | ||||||
| `desktop`, `menubar`, `frontmost-app`, an open of a URL alone) claims the device key. A | ||||||
| `macos-app` lease still takes no claim (ADR 0007). | ||||||
| 2. The per-app record is the process-owned v2 record with `app: { bundleId }`; it decodes only | ||||||
| at the key derived from its identity and app. Classification, takeover, close, idle expiry, | ||||||
| shutdown, the startup sweep, and `device release --stale` treat it like any claim. | ||||||
| 3. Lock order is device key, then app key. Every acquisition on a device holds the device key's | ||||||
| lock; a per-app acquisition takes the app key's lock inside it. Renew, clear, abandon, the | ||||||
| sweep, and stale release take only their own key's lock and never create a claim. | ||||||
| 4. A per-app acquisition settles the device key's claim first: a live foreign whole-device claim | ||||||
| is a conflict. A whole-device acquisition on a macOS device scans the per-app claims of that | ||||||
| device: dead or superseded owners are reconciled and removed, other foreign claims conflict, | ||||||
| and this daemon's own are left to its session store. | ||||||
| 5. In one daemon, a session holds either one app (an app claim, or a `macos-app` lease, which | ||||||
| takes no claim) or the whole device. Two sessions on the device conflict unless each holds one | ||||||
| app and the apps differ. The new-session conflict check and the `open --wait` that queues on it | ||||||
| decide with this one predicate, so a whole-device opener and a same-app opener wait for an app | ||||||
| session and a different-app opener runs beside it. A per-app session may reopen only its app on | ||||||
| the `app` surface; a link opens as `open <app> <url>`, because a bare URL's handler is not known | ||||||
| before launch. | ||||||
| 6. Helper screenshots take a host-wide lock (`macos-screen-capture.lock` in the device claims | ||||||
| directory) for the length of one capture. It is the only action sessions on different apps | ||||||
| serialize on. | ||||||
| 7. App keys are lowercase, and the predicate of rule 5 compares bundle ids without regard to case, | ||||||
| because LaunchServices matches them that way. An `open --wait` resolves the claim it would take | ||||||
| under the device lock; until then it waits only for a whole-device session, and when the open | ||||||
| then finds a conflicting app session it releases the lock and waits for that session too. | ||||||
|
|
||||||
| ## Context | ||||||
|
|
||||||
| The Mac is one device, so its one claim let a single session per Mac drive any macOS app. The | ||||||
| native backend acts on the session app alone: accessibility actions on its elements, key events | ||||||
| posted to its process, window capture, and (rule 7 of ADR 0031) a background `open`. None of | ||||||
| those moves the real pointer or changes the frontmost app, so two sessions on two apps cannot | ||||||
| interfere through focus or the pointer. One host resource is shared: ScreenCaptureKit. Two helper | ||||||
| processes capturing two app windows at the same moment failed (`screenshot failed`, or both hung | ||||||
| until the 30 s helper timeout) in each of four attempts on macOS 27, while each capture alone took | ||||||
| about 0.5 s, so captures run one at a time. | ||||||
|
|
||||||
| ## Decision details | ||||||
|
|
||||||
| **Bundle id, not pid.** The helper resolves its target app by bundle id, so two processes of one | ||||||
| bundle id are one target to every action. A pid in the key would let two sessions drive the same | ||||||
| windows. Liveness is the owning daemon's process, as for every claim. Two copies of one app need | ||||||
| two bundle ids. | ||||||
|
|
||||||
| **Key resolved before the claim.** The bundle id comes from the read-only open-target resolution, | ||||||
| before the claim and before launch. An open whose app cannot be resolved that early takes the | ||||||
| whole device rather than guessing. | ||||||
|
|
||||||
| **What stays whole-Mac.** Surfaces other than `app` post real pointer events or read every app. | ||||||
| Some commands still reach past the session app, as they did before this ADR: the clipboard and | ||||||
| appearance settings (host-global state), `close <other app>` (quits any app, including one another | ||||||
| daemon's session holds), and `settings permission` (opens System Settings in front). | ||||||
|
|
||||||
| **Background open follows the session's scope.** A session that holds one app opens it with | ||||||
| `open -g`, because bringing it forward would move the frontmost app under every other session. | ||||||
| A session that holds the whole Mac, including `open <app> --surface frontmost-app`, opens the app in | ||||||
| front as before. | ||||||
|
|
||||||
| ## Rejected alternatives | ||||||
|
|
||||||
| - **An input lock around every action.** The native actions need neither focus nor the pointer | ||||||
| once `open` stops activating, so a lock would only serialize sessions for nothing. Only the | ||||||
| capture, which contends on ScreenCaptureKit rather than on input, is serialized. | ||||||
| - **A separate claims directory per app.** Two stores could not exclude each other under one | ||||||
| lock, and every stale-claim path would need a second scan. | ||||||
|
|
||||||
| ## Support boundary | ||||||
|
|
||||||
| A daemon older than this ADR reads a per-app record as inconsistent and never clears it, and its | ||||||
|
Contributor
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. P2: The support boundary should state the consequence: a whole-Mac session from an older daemon can run beside an app session, and two sessions can drive the same app. Name that risk so operators know what 'one version per claims directory' prevents. Prompt for AI agents |
||||||
| whole-device acquisition does not scan per-app records. Run one version per claims directory, as | ||||||
| ADR 0030 requires for process locks. | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ import { | |
| } from '@agent-device/host-kit/host-file'; | ||
| import { | ||
| hostPlatform, | ||
| readCurrentOwnerIdentity, | ||
| readHostEnvironmentVariable, | ||
| writeHostStderr, | ||
| } from '@agent-device/host-kit/process'; | ||
|
|
@@ -581,5 +582,33 @@ export async function runMacOsScreenshotAction( | |
| }> { | ||
| const args = ['screenshot', '--out', outPath]; | ||
| appendMacOsHelperContextArgs(args, options); | ||
| return await runMacOsHelper(args, { signal: options.signal }); | ||
| return await withMacOsScreenCaptureLock( | ||
|
Contributor
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. P2: The screenshot's AbortSignal is only forwarded into the inner helper run, not into the lock acquisition. Prompt for AI agents |
||
| async () => await runMacOsHelper(args, { signal: options.signal }), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * ScreenCaptureKit fails or hangs window captures that two processes start at the same time | ||
| * (measured on macOS 27 with two helpers capturing two apps), so every helper capture on this Mac | ||
| * runs alone, whichever daemon asks. The lock sits in the host-global device claims directory, | ||
| * the store every daemon on the host already coordinates through. | ||
| */ | ||
| async function withMacOsScreenCaptureLock<T>(task: () => Promise<T>): Promise<T> { | ||
| const lockRoot = | ||
|
Contributor
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. P3: This re-implements Prompt for AI agents |
||
| readHostEnvironmentVariable('AGENT_DEVICE_CLAIMS_DIR')?.trim() || | ||
| path.join(hostHomeDirectory(), '.agent-device', 'device-claims'); | ||
| await ensureHostDirectory(lockRoot); | ||
| const { acquireProcessLock, withProcessLock } = await import('@agent-device/host-kit/file'); | ||
| return await withProcessLock({ | ||
| acquire: async () => { | ||
| const owner = readCurrentOwnerIdentity(); | ||
| return await acquireProcessLock({ | ||
| lockDirPath: path.join(path.resolve(lockRoot), 'macos-screen-capture.lock'), | ||
| owner: { pid: owner.pid, startTime: owner.startTime, acquiredAtMs: Date.now() }, | ||
| timeoutMs: MACOS_HELPER_TIMEOUT_MS + 5_000, | ||
| description: 'the macOS screen capture lock', | ||
| }); | ||
| }, | ||
| task, | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The new wording says leased apps run beside the host's own sessions, but a whole-Mac session in the same daemon now blocks a leased open. The new-session check runs
sessionConflictsWithOpen, which treats a whole-device session as conflicting with any app opener. Name the exception so the ADR matches ADR 0034 rule 5.Prompt for AI agents