You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(macos): make each native macOS app its own device - #3323
With AGENT_DEVICE_MACOS_APP_BACKEND=native, each app opened on the app surface is its own device (<mac>:app:<bundle id>). The per-device lock, claim, session check, and open --wait apply unchanged, so agents drive different apps of one Mac at once. A host and its app devices overlap; two app devices do not.
An app device opens in the background. It opens and closes only its own app, sends a bare link to that app, keeps the app surface, and refuses clipboard/settings (unsupported-device-scope).
A macos-app lease binds its app device and takes its claim (ADR 0007 amended).
XCTest, desktop, menubar, and frontmost-app keep the whole Mac. Bundle ids match without case.
Replaces the first design (an app scope on the Mac); credits @janicduplessis. Closes#3322; implements #3338. ADR 0035 is "macOS App Devices".
export AGENT_DEVICE_MACOS_APP_BACKEND=native
agent-device open TextEdit --platform macos --session one
agent-device open Calculator --platform macos --session two
Validation
Head 7519086c1f4b83b1214ed30ff100bd05e75e9714 (on main 6ac6611).
Live at this head (raw output), native backend: parallel app sessions in one daemon; cross-daemon conflicts both ways; clipboard refused; frontmost unchanged; kill -9 recovery and cleanup passed; policy (Mac or one app) held; close <other app> refused with the session intact.
Same-Mac locks never wait mid-request, including batch steps. Lease tenants see no foreign owner metadata.
The app-claim design is sound, but c269880 has three defects and no live run at this head, and Coverage fails on the eager-closure budgets.
findSessionHoldingDevice in open-device-contention-wait.ts skips every session whose claim key is not the device key, whatever this open targets. The new-session conflict check uses a different predicate (holdsAnotherAppClaim with this open's app claim key). So when a session holds com.example.one, both open --surface desktop --wait 30000 and open com.example.one --wait 30000 skip the wait and fail at once with DEVICE_IN_USE. Before this PR they waited for the holder to close. The test 'a session holding one app of the Mac is not waited for' uses positionals: [], which is a whole-Mac open, and it asserts this behavior. ADR 0034 rule 7 says only a session holding another app is skipped. The rule: the wait and the new-session conflict check must name the same set of conflicting sessions. Please resolve the open's app claim key before the wait, or pass the conflict predicate into beginOpenDeviceWait, and use one predicate in both places. Then rewrite the test so a whole-Mac opener and a same-app opener wait, and a different-app opener does not.
In session-open-execution.ts, the leaseBackend !== 'macos-app' filter now also applies when an app claim key is set. A lease session takes no claim (ADR 0007). So a local native open com.example.one finds no conflict beside a macos-app lease session on the same app, and it succeeds. Before this PR, findByDevice refused it. The reverse order still conflicts, so the result is asymmetric, and two sessions in one daemon can drive the same app's windows. This looks like it breaks the same-bundle rule from #3322 and the "lease unchanged" claim. I did not trace lease admission end to end, so please confirm. The rule: no two sessions in one daemon drive the same bundle id, whether a claim or a lease scopes them. Treat a lease session as holding its leased bundle in the same predicate as above, and add a test for both orders.
In device-claims.ts, the app path calls resolveExistingClaim on the device key only to decide. For this daemon's own abandoned whole-Mac claim (left by rollbackNewSessionClaim at session-open-execution.ts:731), it returns 'available' and logs a supersede diagnostic. But nothing overwrites or removes the device-key file, and tookOver is dropped. Before this PR, the next open overwrote it. After one failed desktop open, other daemons likely get DEVICE_IN_USE for unrelated apps until this daemon takes the whole Mac again or restarts. I did not reproduce this, and I did not check how a foreign live abandoned claim is classified. The rule: once an app acquisition succeeds, no device-key record may remain unless a live whole-device owner holds it. On 'available' from an abandoned own claim, please unlink the device-key file under the held device lock. Test it by abandoning the whole-Mac claim, opening an app, and checking that a foreign app acquisition succeeds.
This PR changes device-facing paths: open -g in apps.ts, the host-wide capture lock, and claim arbitration across daemons. The only live run used an earlier revision. After the fixes, please run at the new head with AGENT_DEVICE_MACOS_APP_BACKEND=native, two daemons with separate AGENT_DEVICE_STATE_DIR, and a shared claims dir. The run should show: (1) A open <bundleA> and B open <bundleB> both succeed, and device status lists both app claims; (2) lsappinfo front is unchanged after each open; (3) concurrent screenshots from A and B both succeed; (4) a third daemon's open <bundleA> and open --surface desktop both fail with DEVICE_IN_USE or DEVICE_CLAIM_LIVE_OWNER; (5) after kill -9 of A, the desktop open succeeds and A's app claim file is gone; (6) in one daemon, open --surface desktop --wait 30000 beside an app session waits and reports waitedMs. I ran no tests locally. I judged from the diff that the new claim and runtime tests would fail on the old code.
Coverage fails scripts/__tests__/eager-closure-budgets.test.ts for platform-apple: app-lifecycle-facade is 121 against 118, app-resolution-facade is 68 against 60, and macos-facade is 28 against 24. This is likely from this diff, which adds two static imports: apps.ts to os/macos/app-backend.ts to contracts/facades/session.ts (apps.ts:12), and helper.ts to host-kit/file to internal/process-lock.ts. I did not check for conflicts.
Could the open -g decision be made where the surface is known? The daemon open path already knows it is an app-surface open with an app claim, so it could pass background through the openIosApp and openMacOsApp options. That removes the apps.ts to app-backend.ts edge. It also stops open <app> --surface frontmost-app from silently not raising the app. Could the claim scope (app or whole device) also live on DeviceClaimSessionOwnership, with one predicate for the wait, the new-session conflict check and reopen? That replaces three key-string comparisons and closes the first two findings. Could withMacOsScreenCaptureLock load host-kit/file with a dynamic import and take the lock root from one owner? Nothing has to change first except the ownership type gaining app, and ADR 0034 rules 5 and 7 should then describe the unified predicate.
Not blocking: the capture-lock root in helper.ts nearly copies resolveDeviceClaimRoot, and the "is app-scoped" test is recomputed three times from key-string inequality, so you can move the resolver to host-kit and carry app on the ownership, or leave it.
Before merge, the claim-scope fixes need to land, the two eager imports need to move off the platform-apple facades so Coverage passes, and the two-daemon run needs to pass at the new head.
One predicate, sessionConflictsWithOpen: a session holds its claimed app, its leased bundle, or the whole device, and two sessions conflict unless each holds one app and the apps differ. DeviceClaimSessionOwnership (and the session's claim) now carries app, and the wait, the new-session check and reopen all use it; the key-string comparisons are gone. The open's bundle id comes from resolveOpenTarget on the bound runtime, which only exists under the locks, so instead of resolving before the wait: the wait first waits only for whole-device sessions, the open reports the conflict it finds under the lock, and the wait drops that refusal and waits again with the scope known. The test now shows whole-Mac and same-app openers waiting and a different-app opener not; a spent budget reports waitedMs.
A macos-app lease session counts as holding its leased bundle in the same predicate, with a runtime test for both orders. Side effect: two leases on the same bundle (including different pins of one bundle) now conflict; I amended ADR 0007 to say so. Tell me if you'd rather keep lease-vs-lease as before.
When an app acquisition finds the device key available, it removes the device-key file under the held device lock once the app claim is written, and keeps the reboot tookOver. The test abandons the whole-Mac claim, opens an app, and a foreign app acquisition succeeds.
background now goes through OpenApplicationInput, the interactor options, openIosApp and openMacOsApp, set by the daemon only for a session that holds one app; apps.ts no longer imports app-backend.ts, and open <app> --surface frontmost-app raises the app again. withMacOsScreenCaptureLock imports host-kit/file lazily. The eager-closure budgets pass. I left the lock-root resolver where it is.
ADR 0034 rules 5 and 7 describe the predicate and the wait; ADR 0031 and the commands doc match.
Live run at 88b9ead on macOS 27 (native backend, two daemons with their own state dirs, shared claims dir, Calculator and Clock): both app opens succeeded and device status listed both app claims; lsappinfo front was unchanged after each open; concurrent screenshots both succeeded; a third daemon's open com.apple.calculator and open --surface desktop both got DEVICE_IN_USE / DEVICE_CLAIM_LIVE_OWNER; after kill -9 of A, the desktop open succeeded and A's app claim file was gone; open --surface desktop --wait 30000 beside an app session waited and refused with waitedMs: 30001, and succeeded about 4 s in when the app session closed. One note: a background-launched Clock answered open before its first window existed, so a screenshot right after the open got window-not-found; it succeeded about 3 s later. Local: pnpm check:affected --run green (4739 tests), eager-closure 772/772.
This is ready at 88b9ead. The open-wait fix and the abandoned-claim test from the earlier review (c269880) are in, and the eager-closure failure is gone.
Not blocking, and you can take or leave these: (1) when a macos-app lease open waits, admission copies the request's internal object, so the wait's recorded spend in open-device-contention-wait.ts can be lost on the next attempt (the copy is made in lease-lifecycle.ts:118). The spend should be readable from whatever request object the conflict check reads, however often admission copies it; a lease case in the runtime wait test would show it. (2) The wait tests at open-device-contention-wait.test.ts use an inline stand-in, so deleting the reportOpenDeviceConflict call at session-open-execution.ts:420 would keep every unit test green; one handleSessionCommands test with waitMs, where a whole-Mac open waits beside an app session and then succeeds once it retires, would cover it. (3) The JSDoc of the deleted findByDevice is still stacked above listRefs in session-store.ts, and .fallowrc.json and scripts/layering/session-state.ts still name findByDevice.
Could the locked open task return a typed outcome, such as a deferral carrying the opener app, that runWhenDeviceIsUnheld reads, instead of a mutable reportOpenDeviceConflict callback on the request? Nothing would then ride on the request object, so the copy hazard in note 1 could not occur. This is optional, since the current design works for non-lease opens.
The live run at 88b9ead is as you reported it. I did not see a transcript, and I ran no tests myself.
Coverage fails one check, test-file-size-ratchet, because src/daemon/device/__tests__/device-claims.test.ts is now 1005 lines, over the 1000-line tripwire. This PR alone grows that file, so the failure is related. Please split it along the source module it mirrors, for example the app-claim cases into their own file. That is the next thing to fix before merge. I know of no conflicts.
device-claims.test.ts is split: the app-claim cases are now in device-claims-app.test.ts, which brings it to 792 lines and test-file-size-ratchet passes.
Wait spend: beginOpenDeviceWait now creates the openDeviceWait object once and updates it in place, so any copy of internal that lease admission makes reads the same spend. The runtime wait test has a case that copies the request on each attempt; it fails without the fix. I did not do the typed deferral. The open task returns a DaemonResponse through runAdmitted, runLocked and the open handler, so a typed outcome would touch all of those layers. With the spend shared by reference, the copy hazard is gone without that.
A new handleSessionCommands test has a whole-Mac open with waitMs waiting beside an app session and succeeding once it retires. It fails if the reportOpenDeviceConflict call is removed.
Removed the stale findByDevice JSDoc and its mentions in .fallowrc.json and scripts/layering.
pnpm check:affected --run is green at this head (4741 related tests), and eager-closure and the size ratchet pass. I did not repeat the two-daemon live run, since these commits change no device-facing path.
An app-scoped open can still escape its claim through --launch-url. openLaunchPlan leaves macOS launch URLs as a follow-up, and this call invokes openTarget(url); background only adds -g, so the URL may launch its registered browser or another app rather than the claimed bundle. For background/per-app opens, bind the follow-up URL to input.appBundleId (equivalent to open -g -b <bundle> <url>) or reject this option.
The code in fd97751 looks good, and the reconciler change is now fixed, so I have no code findings left. The earlier review at 88b9ead was clean, and this delta only touches the open-wait spend path, the layering scanner entry and the test split. All 16 checks pass at fd97751, so there is no failure to attribute, and there are no conflicts.
I did not run the tests. I checked the spend regression by reading the old recordOpenWaitSpend and tracing the attempt and copy order by hand. I did not reproduce two-daemon behavior live and I rely on your run at 88b9ead, which this delta's device paths do not change. I also did not read prepareOpenCommandDetails for macOS side effects beyond the expireRefFrame call at session-open.ts:233.
Not blocking: the new device-claims-app.test.ts copies the acquireDeviceClaim wrapper, useClaimsRoot, the afterEach cleanup and the mac DeviceInfo fixture from device-claims.test.ts:38-70, and a shared fixtures module would remove that. Take it or leave it.
The open inline threads from the earlier round still apply in part. These still hold at P1/P2 and need a fix or an answer before merge: #3323 (comment) (the "covered" wording), #3323 (comment) (lease scope), #3323 (comment) (ADR 0007 wording), #3323 (comment) (refs expire on refusal), #3323 (comment) (ADR 0031 rule 7), #3323 (comment) (which daemon), #3323 (comment) (one-version caveat), #3323 (comment) (wait is local), and #3323 (comment) (lock ignores signal). Five lower-priority threads also still apply. These do not apply and you can resolve them: #3323 (comment) (own app claims match the existing owned-claim policy), #3323 (comment) (same reason, and a foreign app claim still conflicts), #3323 (comment) (ADR 0034:79-81 already states the rule), #3323 (comment) (not waiting on a foreign daemon is the documented design), #3323 (comment) (the runtime test now runs the real conflict check), #3323 (comment) (the copy order mirrors runAdmitted). These are fixed at this head: #3323 (comment) and #3323 (comment).
No code change is needed before merge. Please fix or answer the doc threads on ADR 0007:190-194, ADR 0031:39 and the cross-daemon caveats at commands.md:351 before a human merges.
A native app-surface open now binds an app device, <mac id>:app:<bundle id>,
instead of an app scope on the Mac device. The per-device execution lock,
claim, conflict check, and open --wait then apply unchanged, so two app
sessions run in parallel in one daemon too. One rule relates a host to its
app devices: they overlap, and two app devices do not.
- Device selection resolves the app before any lock through a root-composed
resolver, only for the native backend.
- The claim store keys app device claims by the app device identity; the
record needs no app field. The host lock still orders before the app lock.
- An app device opens in the background, opens and closes only its own app,
routes a bare link to that app, keeps the app surface, and refuses the
clipboard and host settings through runtime facts.
- A macos-app lease binds the app device and takes its claim.
- Removes the scope predicate, the two-phase open wait, the lease/claim
bridge, the reopen guard, and the background-open plumbing.
- Bundle ids match without regard to case.
ADR 0035 becomes "macOS App Devices"; ADR 0007 and ADR 0031 are amended.
Co-authored-by: Janic Duplessis <janicduplessis@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A replay or batch that holds an app device's execution lock and then opens
the whole Mac retained the host lock out of the host-then-app order, so it
could deadlock with a fresh open that held the host and waited for the app.
Retaining a host's lock while holding one of its app devices now fails with
UNSUPPORTED_OPERATION (reason host-after-app-device-lock) instead of waiting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App devices made two requests on one Mac concurrent where they used to share
the Mac's execution lock. Two replays on two apps that each close their app
and open the other's, or a replay that leaves its app for the whole Mac while
a fresh open holds the Mac, could then each wait for the lock the other held.
A request that holds a lock on a Mac or one of its apps now takes another lock
on that Mac mid-request only while it is free, and is otherwise refused with
DEVICE_IN_USE (reason DEVICE_EXECUTION_LOCK_BUSY). Locks on other devices are
still waited for. This replaces the narrower host-after-app-device-lock rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App devices took a derived id, so a `devices.allow` entry for the Mac
refused every native app open, and an app-only entry could never be
selected because inventory hid its Mac. The policy now admits an app device
whose host it allows. An allowed app device id admits that app alone.
Inventory lists the host of an allowed app device so selection can derive
it, while binding the host itself stays denied.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p devices
- A daemon policy app device id matches whatever case its bundle part uses.
- On a Mac the policy does not allow, app name resolution reports no
installed apps: a failed resolution leaves the Mac selected, which
binding refuses.
- An app device refuses audio probe, which samples the whole Mac's audio.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Policy device ids compare canonicalized on both sides, so an id that
merely contains `:app:` still matches itself.
- On a Mac the policy does not allow, app resolution returns only an allowed
app device, so an installed but disallowed app answers like a missing one.
- The audio refusal carries the capture fact's reason in its details.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A batch step runs as a full request and takes its own initial locks while
its batch still holds the batch session's app device lock. Two batches in
app sessions that each close their app and open the other's could then
cycle: one step took the Mac's lock and waited for the other batch's app,
while the other step waited for the Mac. Each request now records the locks
it holds for the requests nested in it, and a nested request takes a lock on
a Mac its enclosing requests already hold a lock on only while it is free,
otherwise refused with DEVICE_IN_USE (DEVICE_EXECUTION_LOCK_BUSY). Locks on
other devices are still waited for, and cancellation is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A batch step whose lock plan resolved no device and that reopens the batch
session's app retained a lock its batch already held. The same-Mac rule saw
it as busy. A lock an enclosing request holds is now re-entered, as initial
locks already were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thymikee The branch is current: your rebase onto main is 4da13ff, and its tree is identical to my earlier merge commit bc89dac. On top of it, as a fast-forward, I added three daemon-policy commits (f84f914, bb21a39, 0ff13a5) and two nested-batch lock commits (783cfe2, 409b8d5).
At the final head 409b8d5:
check:affected --run --head passed: run-mMgeBS, 11,810 related tests.
The live native run, including daemon-policy cases, passed.
No review threads are open.
Evidence from earlier runs is attributed to their own SHAs in the threads.
Limit the host lock to first-open admission and publication. This branch also handles fresh replays, and request-execution-scope.ts:365–368 holds the initial locks until the entire replay finishes. After a replay opens app A, a fresh open or replay for unrelated app B therefore waits on the Mac's lock throughout A's remaining actions. Long replays serialize agents whose app devices do not overlap. Separate the short host admission lock from the replay-lifetime session/app locks, preserving the host/app conflict checks and nested-lock safeguards. Add coverage showing that B can open while A continues its replay.
Validate close targets before tearing down sessions
Validate the app-device close target before session teardown. After opening TextEdit, an ordinary close Calculator --session <textedit-session> reaches this refusal inside runSessionCloseTeardown, after resource cleanup has begun. runCloseTeardownAndRelease then retains the claim because platformCloseError is set, but still retires the session (session-close.ts:347–363). The invalid request therefore removes the usable session and leaves a live claim blocking other sessions. Check the target at close admission, keep this platform guard, and add a close-handler regression asserting that a rejected target leaves the session and its resources intact.
… bad close targets
- A `macos-app` lease tenant sees a busy app as a generic DEVICE_IN_USE with
its typed reason, not the other owner's session, workspace, state
directory, or host recovery command, and a reboot takeover warning does
not name the previous owner.
- Only a fresh `open` of an app device takes its host's execution lock. A
replay holds its locks for its whole run, so it locks only its app device;
its opens still run under the first-open lock keyed by the host.
- `close <app>` in an app device session resolves its target through the
platform before any teardown (a new close plan use), so another app is
refused while the session and its claim stay intact, and any name of the
session's own app still closes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot review 5602953660 raised two issues outside review threads. Both are fixed in 7519086, along with the lease-privacy thread.
Host lock scope. Only a fresh open of an app device takes the Mac's execution lock, so open --wait still checks the Mac's sessions under it. A replay holds its locks for its whole run, so it now locks only its app device. Its opens still run under the first-open lock keyed by the host. The replay lock-plan test asserts this, and it fails on 409b8d5. One known limit remains: a fresh batch --platform macos with no app target still selects the whole Mac, so it holds that lock for its run. ADR 0035 rule 3 states this.
Close target before teardown.close <app> in an app-device session now resolves its target through the platform before any teardown, using a new close plan use, resolveCloseTargetRuntimeUse. Another app is refused with app-device-scope while the session and its claim stay intact, and any name of the session's own app still closes it. Tests:
session-device-claims.test.ts covers the handler: the session and its claim stay intact.
apps.test.ts covers the platform: an alias is accepted and another app is refused.
The live run at 7519086 showed close Calculator refused with the session and its claim kept, and close TextEdit by name succeeding after open com.apple.TextEdit.
Validation at 7519086: check:affected --run --head passed (run-gzpZB4, 11,813 related tests). The live native run passed: apps ran in parallel, conflicts held across daemons in both orders, clipboard refused, frontmost app unchanged, kill -9 recovery worked, the policy cases held, the close cases held, and no claims were left.
The code at 7519086 looks correct to me, and the lock fixes from the earlier review at aefe920 (#3323 (comment)) now hold: a second same-Mac lock taken mid-request is refused instead of waiting, crossed app A/B replays are refused instead of deadlocking, and the macOS app lease no longer leaks the previous owner. All 14 checks pass at 7519086, and there are no conflicts. What is still missing is raw output from the live native macOS run at 7519086: it is only described in a maintainer comment, so please attach the output that shows two app devices on one Mac each opening, running and closing without disturbing the other. Not blocking, and you can take or leave these: the lock rule in request-execution-locks.ts now keeps a second registry of held locks (the enclosingRequestLocks store, the heldLocks set and the beforeAcquire hook) beside the kernel keyed-lock, and it needs two re-entrance special cases; the close-refusal test in session-device-claims.test.ts could build its error by calling resolveMacOsAppDeviceOpenTarget with a stub host, so the handler sees the production error. Could the question "which locks does this async context hold, including retained ones" live only in packages/kernel/src/keyed-lock.ts, with a hold primitive that registers a retained frame in the caller's context and a held-keys query, so the same-host rule becomes one check and those special cases go away? The app-device identity can stay as it is. The five open inline threads from the earlier round all apply to code that is now fixed, so please resolve them: #3323 (comment), #3323 (comment), #3323 (comment), #3323 (comment) and #3323 (comment). Before merge, please attach the live run output and decide whether to accept the lock registry or move it into keyed-lock. Then rebase against #3867, which edits the same lines in apps.ts and macos-app-lease.ts; whichever PR lands second must resolve that conflict.
Raw output of the live native macOS run at exact head 7519086c1f4b83b1214ed30ff100bd05e75e9714, as the reviewer asked in #issuecomment-6322558994. This is the output of the run reported in #issuecomment-6321624952. Nothing was rerun to attach it.
When: 2026-10-21. The four scripts ran back to back and finished at 16:55:21, 16:55:23, 16:55:27 and 16:55:30 UTC (file modification times).
Where: one host Mac on macOS 27.0, AGENT_DEVICE_MACOS_APP_BACKEND=native, source build from the worktree (pnpm build at that SHA, then node bin/agent-device.mjs). Every daemon used its own temporary state directory under /private/tmp, with one shared temporary AGENT_DEVICE_CLAIMS_DIR per script.
Redacted: the name of the Mac (<mac-name>), home and worktree paths (~, <worktree>), the unrelated app that was frontmost on the host (<unrelated-frontmost-app>), its lsappinfo ASN, and the local account name (<user>). No tokens appear in the output. Times inside ls listings are local time (UTC+2).
What the output shows
run.sh:
Two app devices in one daemon, TextEdit (opened by mixed-case bundle id) and Calculator, open; their concurrent 3 s waits both finish in 3.4 s.
In-session open Calculator and --surface desktop are refused with app-device-scope.
A second daemon gets DEVICE_IN_USE for the same app and for the desktop surface, then opens Clock beside them.
clipboard is refused with unsupported-device-scope.
Three concurrent screenshots across two daemons succeed.
After kill -9 of daemon B, daemon C opens Clock.
Cleanup closes everything and stops the daemons, leaving claims: [].
reverse.sh: with a whole-Mac desktop session held, app opens are refused from the same daemon and from another daemon. Cleanup leaves 0 claims.
policy.sh:
With devices.allow naming the Mac, TextEdit opens as an app device.
With only host-macos-local:app:com.apple.TextEdit allowed (mixed case), TextEdit opens; Calculator, an unknown app name and the desktop surface get DAEMON_POLICY_DENIED.
Cleanup leaves 0 claims.
close.sh:close Calculator in the TextEdit session is refused with app-device-scope while the session and its claim stay. close TextEdit, by name after opening by bundle id, then closes it. 0 claims are left.
Limitations
The frontmost check reads lsappinfo front. It shows the frontmost app staying the same; it does not inspect windows.
At an earlier head (da4026f), one run of two showed TextEdit frontmost after the opens. Later runs did not reproduce that, but it is not proven impossible.
The lease reboot-takeover warning and the crossed-batch lock refusal were not exercised live; unit and router tests cover them.
One host and one OS version were used.
run.sh (two app devices, two and three daemons)
ROOT=/private/tmp/ad3323-live-E9jrtS
source=7519086c1f4b83b1214ed30ff100bd05e75e9714
=== frontmost before ===
[ NULL ] ASN:<asn>: (in front)
bundleID="<unrelated-frontmost-app>"
bundle path=[ NULL ]
executable path=[ NULL ]
!cgsConnection !signalled type=[ NULL ] flavor=[ NULL ] Version=[ NULL ] Arch=!!none
=== (1) daemon A: open com.apple.TextEdit (mixed case) --session one ===
{"ok":true,"device":"<mac-name>","id":"host-macos-local:app:com.apple.textedit","appBundleId":"com.apple.TextEdit"}
=== (1) daemon A: open Calculator --session two ===
{"ok":true,"device":"<mac-name>","id":"host-macos-local:app:com.apple.calculator","appBundleId":"com.apple.calculator"}
=== frontmost after app opens ===
[ NULL ] ASN:<asn>: (in front)
bundleID="<unrelated-frontmost-app>"
bundle path=[ NULL ]
executable path=[ NULL ]
!cgsConnection !signalled type=[ NULL ] flavor=[ NULL ] Version=[ NULL ] Arch=!!none
=== (1) one daemon, two app sessions: concurrent wait 3000 (parallel ~3s, serialized ~6s) ===
{"ok":true}
{"ok":true}
two done at +3326ms
one done at +3328ms
both done after 3437ms
=== (1b) in-session: open Calculator --session one (other app) ===
{"ok":false,"code":"INVALID_ARGS","reason":"app-device-scope","msg":"This session drives com.apple.TextEdit as its own device, so it cannot open or close com.apple.calculator."}
=== (1b) in-session: open --surface desktop --session one ===
{"ok":false,"code":"INVALID_ARGS","reason":"app-device-scope","msg":"This session holds com.apple.TextEdit as its own device, so it opens only the app surface. Use a new --session for --surface desktop."}
=== (2) daemon B: open TextEdit --session three (same app, other daemon) ===
{"ok":false,"code":"DEVICE_IN_USE","reason":"DEVICE_CLAIM_LIVE_OWNER","msg":"macos device host-macos-local:app:com.apple.textedit is owned by session \"one\" in workspace \"<worktree>\"."}
=== (2) daemon B: open --surface desktop --session four ===
{"ok":false,"code":"DEVICE_IN_USE","reason":"DEVICE_CLAIM_LIVE_OWNER","msg":"macos device host-macos-local:app:com.apple.calculator is owned by session \"two\" in workspace \"<worktree>\"."}
=== (2) daemon B: open Clock --session five (different app, other daemon) ===
{"ok":true,"device":"<mac-name>","id":"host-macos-local:app:com.apple.clock","appBundleId":"com.apple.clock"}
=== (3) clipboard read --session one ===
{"ok":false,"code":"UNSUPPORTED_OPERATION","reason":"unsupported-device-scope","msg":"clipboard is not supported on this device"}
=== (5) concurrent screenshots: one, two (A), five (B) ===
{"ok":true}
{"ok":true}
{"ok":true}
-rw-r--r--@ 1 <user> wheel 358038 Oct 21 18:55 /private/tmp/ad3323-live-E9jrtS/five.png
-rw-r--r--@ 1 <user> wheel 73621 Oct 21 18:55 /private/tmp/ad3323-live-E9jrtS/one.png
-rw-r--r--@ 1 <user> wheel 65773 Oct 21 18:55 /private/tmp/ad3323-live-E9jrtS/two.png
=== device status (claims) ===
[{"deviceKey":"local:apple:macos:host-macos-local:app:com.apple.calculator","classification":"live"},{"deviceKey":"local:apple:macos:host-macos-local:app:com.apple.textedit","classification":"live"},{"deviceKey":"local:apple:macos:host-macos-local:app:com.apple.clock","classification":"live"}]
0148c536d0ccec0b4b1402508b46f7af46d284a9512e5c5edfda5130b0520a3d.json
255e6e0d36afb0c479a9e71966c9cd99f62b1a647b964a9207096913ec2710e8.json
65542944481c1a63c0fd68628ffbffc45c80335223f451ef3dd252b7045d3e09.json
=== frontmost at end ===
[ NULL ] ASN:<asn>: (in front)
bundleID="<unrelated-frontmost-app>"
bundle path=[ NULL ]
executable path=[ NULL ]
!cgsConnection !signalled type=[ NULL ] flavor=[ NULL ] Version=[ NULL ] Arch=!!none
=== (6) kill -9 daemon B, then daemon C opens Clock ===
daemon B pid=1131
{"ok":true,"device":"<mac-name>","id":"host-macos-local:app:com.apple.clock","appBundleId":"com.apple.clock"}
=== cleanup ===
{"ok":true}
{"ok":true}
{"ok":true}
{"ok":true}
{"ok":true}
{
"success": true,
"data": {
"claims": []
}
}
claims left:
total 0
drwxr-xr-x 2 <user> wheel 64 Oct 21 18:55 .
drwx------ 9 <user> wheel 288 Oct 21 18:55 ..
reverse.sh (whole-Mac session first)
source=7519086c1f4b83b1214ed30ff100bd05e75e9714
== D: open --surface desktop --session desk
{"ok":true,"id":"host-macos-local"}
== D (same daemon): open TextEdit --session app
{"ok":false,"code":"DEVICE_IN_USE","msg":"Device is already in use by session \"desk\"."}
== E (other daemon): open TextEdit --session app
{"ok":false,"code":"DEVICE_IN_USE","reason":"DEVICE_CLAIM_LIVE_OWNER","msg":"macos device host-macos-local is owned by session \"desk\" in workspace \"<worktree>\"."}
== cleanup
{"ok":true}
{"ok":true}
{"ok":true}
claims left: 0
policy.sh (daemon policy)
source=7519086c1f4b83b1214ed30ff100bd05e75e9714
== policy allows the Mac: open TextEdit
{"ok":true,"id":"host-macos-local:app:com.apple.textedit"}
{"ok":true}
== policy allows only TextEdit (mixed case): open TextEdit / Calculator / desktop
{"ok":true,"id":"host-macos-local:app:com.apple.textedit"}
{"ok":false,"code":"UNAUTHORIZED","reason":"DAEMON_POLICY_DENIED","rule":"device"}
{"ok":false,"code":"UNAUTHORIZED","reason":"DAEMON_POLICY_DENIED","rule":"device"}
{"ok":false,"code":"UNAUTHORIZED","reason":"DAEMON_POLICY_DENIED","rule":"device"}
{"ok":true}
claims left: 0
close.sh (close target scope)
source=7519086c1f4b83b1214ed30ff100bd05e75e9714
== open com.apple.TextEdit --session te
{"ok":true,"id":"host-macos-local:app:com.apple.textedit"}
== close Calculator --session te (other app)
{"ok":false,"code":"INVALID_ARGS","reason":"app-device-scope"}
== session te still open, claims: 1
["te"]
== close TextEdit --session te (own app, by name)
{"ok":true}
claims left: 0
Scripts that produced this output (commands)
# ---- run.sh#!/bin/zsh# Live acceptance for PR #3323 (macOS app devices). Usage: run.sh <worktree>set -u
WT=$1
ROOT=$(mktemp -d /private/tmp/ad3323-live-XXXXXX)
A=$ROOT/a; B=$ROOT/b; C=$ROOT/c; CL=$ROOT/claims
mkdir -p $A$B$C$CLexport AGENT_DEVICE_MACOS_APP_BACKEND=native
export AGENT_DEVICE_CLAIMS_DIR=$CLAD() { node $WT/bin/agent-device.mjs "$@"; }
step() { print -- "\n=== $* ==="; }
ts() { node -e 'process.stdout.write(String(Date.now()))'; }
summ() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const o=j.success?{ok:true,device:j.data?.device,id:j.data?.id,appBundleId:j.data?.appBundleId}:{ok:false,code:j.error?.code,reason:j.error?.details?.reason,session:j.error?.details?.session,msg:j.error?.message};console.log(JSON.stringify(o))}catch(e){console.log("RAW:",s.slice(0,600))}})'; }
print "ROOT=$ROOT"
print "source=$(git -C $WT rev-parse HEAD)"
step "frontmost before"; lsappinfo info -only bundleid "$(lsappinfo front)"
step "(1) daemon A: open com.apple.TextEdit (mixed case) --session one"
AD open com.apple.TextEdit --platform macos --session one --state-dir $A --json | summ
step "(1) daemon A: open Calculator --session two"
AD open Calculator --platform macos --session two --state-dir $A --json | summ
step "frontmost after app opens"; lsappinfo info -only bundleid "$(lsappinfo front)"
step "(1) one daemon, two app sessions: concurrent wait 3000 (parallel ~3s, serialized ~6s)"
T0=$(ts)
( AD wait 3000 --session one --state-dir $A --json | summ; print "one done at +$(( $(ts) - T0))ms" ) &
( AD wait 3000 --session two --state-dir $A --json | summ; print "two done at +$(( $(ts) - T0))ms" ) &wait
print "both done after $(( $(ts) - T0))ms"
step "(1b) in-session: open Calculator --session one (other app)"
AD open Calculator --platform macos --session one --state-dir $A --json | summ
step "(1b) in-session: open --surface desktop --session one"
AD open --platform macos --surface desktop --session one --state-dir $A --json | summ
step "(2) daemon B: open TextEdit --session three (same app, other daemon)"
AD open TextEdit --platform macos --session three --state-dir $B --json | summ
step "(2) daemon B: open --surface desktop --session four"
AD open --platform macos --surface desktop --session four --state-dir $B --json | summ
step "(2) daemon B: open Clock --session five (different app, other daemon)"
AD open Clock --platform macos --session five --state-dir $B --json | summ
step "(3) clipboard read --session one"
AD clipboard read --session one --state-dir $A --json | summ
step "(5) concurrent screenshots: one, two (A), five (B)"
sleep 3
( AD screenshot $ROOT/one.png --session one --state-dir $A --json | summ ) &
( AD screenshot $ROOT/two.png --session two --state-dir $A --json | summ ) &
( AD screenshot $ROOT/five.png --session five --state-dir $B --json | summ ) &wait
ls -la $ROOT/*.png
step "device status (claims)"
AGENT_DEVICE_STATE_DIR=$A AD device status --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);const c=j.data?.claims??j.data;console.log(JSON.stringify((Array.isArray(c)?c:[]).map(x=>({deviceKey:x.claim?.deviceKey??x.deviceKey,session:x.claim?.session??x.session,classification:x.classification}))))})'
ls $CL
step "frontmost at end"; lsappinfo info -only bundleid "$(lsappinfo front)"
step "(6) kill -9 daemon B, then daemon C opens Clock"
BPID=$(node -e 'const j=require(process.argv[1]);console.log(j.pid)'$B/daemon.json 2>/dev/null)
print "daemon B pid=$BPID";kill -9 $BPID; sleep 1
AD open Clock --platform macos --session six --state-dir $C --json | summ
step "cleanup"
AD close --session one --state-dir $A --json | summ
AD close --session two --state-dir $A --json | summ
AD close --session six --state-dir $C --json | summ
AD daemon stop --state-dir $A --json | summ
AD daemon stop --state-dir $C --json | summ
AGENT_DEVICE_STATE_DIR=$A AD device status --stale --json | head -c 400; print
print "claims left:"; ls -la $CL# ---- reverse.sh#!/bin/zshset -u
WT=$1; ROOT=$(mktemp -d /private/tmp/ad3323-rev-XXXXXX); D=$ROOT/d; E=$ROOT/e; CL=$ROOT/claims; mkdir -p $D$E$CLexport AGENT_DEVICE_MACOS_APP_BACKEND=native AGENT_DEVICE_CLAIMS_DIR=$CLAD() { node $WT/bin/agent-device.mjs "$@"; }
summ() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(JSON.stringify(j.success?{ok:true,id:j.data?.id}:{ok:false,code:j.error?.code,reason:j.error?.details?.reason,msg:j.error?.message}))})'; }
print "source=$(git -C $WT rev-parse HEAD)"
print "== D: open --surface desktop --session desk"; AD open --platform macos --surface desktop --session desk --state-dir $D --json | summ
print "== D (same daemon): open TextEdit --session app"; AD open TextEdit --platform macos --session app --state-dir $D --json | summ
print "== E (other daemon): open TextEdit --session app"; AD open TextEdit --platform macos --session app --state-dir $E --json | summ
print "== cleanup"; AD close --session desk --state-dir $D --json | summ
AD daemon stop --state-dir $D --json | summ; AD daemon stop --state-dir $E --json | summ
print "claims left: $(ls $CL| wc -l)"# ---- policy.sh#!/bin/zshset -u
WT=$1; ROOT=$(mktemp -d /private/tmp/ad3323-pol-XXXXXX); mkdir -p $ROOT/h $ROOT/a $ROOT/c
export AGENT_DEVICE_MACOS_APP_BACKEND=native AGENT_DEVICE_CLAIMS_DIR=$ROOT/c
AD() { node $WT/bin/agent-device.mjs "$@"; }
summ() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(JSON.stringify(j.success?{ok:true,id:j.data?.id}:{ok:false,code:j.error?.code,reason:j.error?.details?.reason,rule:j.error?.details?.rule}))})'; }
print "source=$(git -C $WT rev-parse HEAD)"
print '{"version":1,"devices":{"allow":[{"udid":"host-macos-local"}]}}'>$ROOT/host.json
print '{"version":1,"devices":{"allow":[{"udid":"host-macos-local:app:com.apple.TextEdit"}]}}'>$ROOT/app.json
print "== policy allows the Mac: open TextEdit"
AGENT_DEVICE_DAEMON_POLICY=$ROOT/host.json AD open TextEdit --platform macos --session p1 --state-dir $ROOT/h --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/host.json AD close --session p1 --state-dir $ROOT/h --json | summ
print "== policy allows only TextEdit (mixed case): open TextEdit / Calculator / desktop"
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD open TextEdit --platform macos --session p2 --state-dir $ROOT/a --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD open Calculator --platform macos --session p3 --state-dir $ROOT/a --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD open NoSuchAppXyz --platform macos --session p5 --state-dir $ROOT/a --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD open --platform macos --surface desktop --session p4 --state-dir $ROOT/a --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD close --session p2 --state-dir $ROOT/a --json | summ
fordin$ROOT/h $ROOT/a;do AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD daemon stop --state-dir $d --json >/dev/null 2>&1;done
print "claims left: $(ls $ROOT/c | wc -l)"# ---- close.sh#!/bin/zshset -u
WT=$1; ROOT=$(mktemp -d /private/tmp/ad3323-close-XXXXXX); mkdir -p $ROOT/s $ROOT/c
export AGENT_DEVICE_MACOS_APP_BACKEND=native AGENT_DEVICE_CLAIMS_DIR=$ROOT/c
AD() { node $WT/bin/agent-device.mjs "$@"; }
summ() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(JSON.stringify(j.success?{ok:true,id:j.data?.id}:{ok:false,code:j.error?.code,reason:j.error?.details?.reason}))})'; }
print "source=$(git -C $WT rev-parse HEAD)"
print "== open com.apple.TextEdit --session te"; AD open com.apple.TextEdit --platform macos --session te --state-dir $ROOT/s --json | summ
print "== close Calculator --session te (other app)"; AD close Calculator --session te --state-dir $ROOT/s --json | summ
print "== session te still open, claims: $(ls $ROOT/c | wc -l)"; AD session list --state-dir $ROOT/s --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(JSON.stringify((j.data?.sessions??[]).map(x=>x.name)))})'
print "== close TextEdit --session te (own app, by name)"; AD close TextEdit --session te --state-dir $ROOT/s --json | summ
AD daemon stop --state-dir $ROOT/s --json >/dev/null 2>&1
print "claims left: $(ls $ROOT/c | wc -l)"
Status at posting: the head is still 7519086. #3867 is open unmerged and edits the same lines of apps.ts and macos-app-lease.ts. This branch is not rebased onto unmerged work; whichever PR merges second resolves the overlap. The optional move of the lock registry into keyed-lock is pending a maintainer decision and does not affect this evidence.
Decision: lock registry. This PR keeps the request-level lock registry in request-execution-locks.ts as it is at 7519086, and moving lock ownership into keyed-lock is a follow-up, tracked in #3876.
Reasons:
The current code is reviewed, green, and verified live.
The move changes a kernel primitive that every daemon request uses, and it has at least one trap: a lock retained inside a nested scope must stay visible to later sibling scopes. That deserves its own review.
#3876 also takes the close-test suggestion: build that test's error from the production platform refusal. Nothing else is pending here. #3867 is still unmerged, so whichever PR merges second resolves the overlap in apps.ts and macos-app-lease.ts.
Thanks. The raw live output at 7519086 covers what was missing: two app devices on one Mac open, run and close without disturbing each other, across daemons and after a kill -9. The decision to keep the lock registry here and move it into keyed-lock in #3876 is clear, and the nested-scope trap is a good reason to review that move on its own. All review threads are resolved and there are no conflicts, so this is ready for a human review. #3867 still touches apps.ts and macos-app-lease.ts, so whichever PR merges second resolves that overlap.
The keyed-lock follow-up is open as #3879 (closes #3876). It is stacked on this PR through the base branch stack/pr-3323-app-devices at 7519086. This PR needs no change for it and can merge on its own; #3879 will be retargeted to main after it does.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
ready-for-humanValid work that needs human implementation, judgment, or maintainer merge
3 participants
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
With
AGENT_DEVICE_MACOS_APP_BACKEND=native, each app opened on theappsurface is its own device (<mac>:app:<bundle id>). The per-device lock, claim, session check, andopen --waitapply unchanged, so agents drive different apps of one Mac at once. A host and its app devices overlap; two app devices do not.appsurface, and refusesclipboard/settings(unsupported-device-scope).macos-applease binds its app device and takes its claim (ADR 0007 amended).desktop,menubar, andfrontmost-appkeep the whole Mac. Bundle ids match without case.Replaces the first design (an app scope on the Mac); credits @janicduplessis. Closes #3322; implements #3338. ADR 0035 is "macOS App Devices".
export AGENT_DEVICE_MACOS_APP_BACKEND=native agent-device open TextEdit --platform macos --session one agent-device open Calculator --platform macos --session twoValidation
Head
7519086c1f4b83b1214ed30ff100bd05e75e9714(on main6ac6611).check:affected --run --head <head>: passed,run-gzpZB4,exactCommit: true, 11,813 related tests.kill -9recovery and cleanup passed; policy (Mac or one app) held;close <other app>refused with the session intact.🤖 Generated with Claude Code