Skip to content

feat(macos): claim one app per native macOS app session - #3323

Open
janicduplessis wants to merge 13 commits into
callstack:mainfrom
janicduplessis:feat/macos-per-app-claims
Open

janicduplessis wants to merge 13 commits into
callstack:mainfrom
janicduplessis:feat/macos-per-app-claims

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Native-backend macOS app sessions claim <host key>:app:<bundleId> instead of the whole Mac, so agents with separate daemons can drive different apps at once. Whole-Mac and per-app claims exclude each other (lock order host, then app); same-bundle-id sessions conflict. Native open uses open -g so it no longer activates the app. screenshot takes a host-wide capture lock because concurrent ScreenCaptureKit captures fail or hang on macOS 27. The macos-app lease is unchanged. Design: ADR 0034.

Closes #3322. Related: #3254, #3229, #3213, #3236.

20 files, +766/-49 (about 340 test lines, 80 ADR lines).

Validation

  • pnpm check:affected --run passed at c269880 (rebased on origin/main; one fix for the isRecord helper moved upstream).
  • Live run on macOS 27.0 with the native backend, two fixture apps, two daemons, run on an earlier revision of this branch: concurrent open/snapshot/click/fill/type/screenshot succeeded, frontmost app unchanged, third same-app and whole-Mac sessions got DEVICE_IN_USE. Not re-run after the rebase.
  • CI Integration Tests and Coverage not yet checked.

For maintainers

Documented in ADR 0034, not changed:

  • close <other app> and settings permission reach past the session app.
  • open -g applies to every native surface, so open <app> --surface frontmost-app no longer raises the app.
  • Capture-lock acquisition ignores request cancellation (waits at most 35 s).
  • devices does not show per-app claims on the host row.

Open: mixed versions on one claims dir do not exclude each other, since an older daemon taking the host key does not scan per-app files.

View guided diff

Native macOS app sessions claim <device key>:app:<bundleId> instead of
the whole Mac, so sessions driving different apps run at the same time.
Whole-device acquisitions on a Mac scan and settle per-app claims under
the device key's lock (device key, then app key). Under the native
backend, open launches apps with open -g so the frontmost app stays in
front. ADR 0034.
Two helper processes capturing app windows with ScreenCaptureKit at the
same moment fail or hang until the helper timeout. Native app sessions
now run side by side, so helper screenshots take a host-wide process
lock for the length of one capture.
- open --wait no longer waits for a session holding another app
- app claim keys are lowercase, as LaunchServices matches bundle ids
- a held whole-Mac claim of the same session refuses a per-app open
- the claimed bundle id must match the one preparation resolved
- the capture lock lives in the claims directory, hermetic in tests
- ADR 0034 lists what still reaches past the session app
@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member

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.

…s an app claim

An app acquisition that found the device key available left this
daemon's own abandoned whole-Mac record in place, which kept fencing
every other daemon's app opens. Remove the device-key record under the
device lock once the app claim is taken, and keep the reboot takeover the
device-key settlement reported.
The open wait skipped every session holding an app claim, while the
new-session check compared claim keys and ignored macos-app lease
sessions. A whole-Mac or same-app open --wait therefore failed at once
beside an app session, and a native open of a leased bundle succeeded
beside the lease session while the reverse order conflicted.

A claim ownership now carries the app it holds. sessionConflictsWithOpen
treats a session as holding its claimed app, its leased bundle, or the
whole device, and both the new-session check and the wait use it. The
open reports the conflict it finds under the device lock, so a wait whose
opener scope was not known before the lock drops that refusal and waits
for the conflicting session. Reopen compares the claimed app instead of
key strings. SessionStore.findByDevice has no caller left and is removed.
…ng the backend

openMacOsApp read the host backend through app-backend.ts, which put the
session contracts facade in the eager closure of the platform-apple
facades, and it opened every native-backend app with open -g, so
open <app> --surface frontmost-app no longer raised the app.

OpenApplicationInput, the interactor open options, openIosApp and
openMacOsApp now take background. The daemon sets it for a session that
holds one app, through an app claim or a macos-app lease, and leaves a
whole-Mac session's open in front.
…s the capture lock

The static host-kit/file import put internal/process-lock.ts in the eager
closure of the platform-apple macOS facade.
…claims

ADR 0034 rules 5 and 7 state the predicate shared by the new-session
check and open --wait, and the background open now follows the session's
scope. ADR 0007 and ADR 0031 and the macOS command notes match.
@janicduplessis

Copy link
Copy Markdown
Contributor Author

Thanks, all addressed in 0586e88..88b9ead.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member

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.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 9, 2026
@janicduplessis
janicduplessis marked this pull request as ready for review October 9, 2026 02:36
Copilot AI balanced review requested due to automatic review settings October 9, 2026 02:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cross-daemon leases and same-daemon transient claims can bypass the intended app-versus-host exclusion.

2 open findings
What changed in this PR

Adds per-app claims for native macOS sessions, enabling concurrent automation of different applications while preserving whole-device exclusion.

Changes:

  • Adds app-scoped claim records, conflict handling, waiting, and coverage.
  • Launches scoped apps in the background and serializes ScreenCaptureKit captures.
  • Documents the design through ADR 0034 and command guidance.
File Description
website/​docs/​docs/​commands.md Documents per-app concurrency and exclusions.
src/​daemon/​session-store.ts Removes single-session device lookup.
src/​daemon/​session-state.ts Adds app claim metadata.
src/​daemon/​session-lifecycle/​internal/​session-open.ts Enforces app scope on reopen.
src/​daemon/​session-lifecycle/​internal/​session-open-execution.ts Resolves and acquires app-scoped claims.
src/​daemon/​session-lifecycle/​internal/​__tests__/​session-open-execution-runtime.test.ts Tests scoped opens and lease conflicts.
src/​daemon/​open-device-contention-wait.ts Makes waiting app-scope aware.
src/​daemon/​handlers/​__tests__/​session-device-claims.test.ts Adds lease device-key fixture data.
src/​daemon/​device/​device-claims.ts Implements app claim arbitration.
src/​daemon/​device/​device-claim-record.ts Extends persisted claim schema.
src/​daemon/​device/​device-claim-paths.ts Defines canonical app claim keys.
src/​daemon/​device/​__tests__/​device-claims.test.ts Tests claim coexistence and cleanup.
src/​daemon/​device/​__tests__/​device-claim-record.test.ts Tests app claim decoding.
src/​daemon/​daemon-request.ts Adds open-conflict reporting state.
src/​daemon/​__tests__/​session-store-lifetime.test.ts Updates removed lookup coverage.
src/​daemon/​__tests__/​request-execution-scope-open-device-wait.test.ts Updates device lookup in fixtures.
src/​daemon/​__tests__/​open-device-contention-wait.test.ts Tests app-aware waiting.
src/​daemon/​__tests__/​application-lifecycle-runtime-fixture.ts Carries background-open context.
packages/​platform-apple/​src/​os/​macos/​host-provider.ts Adds open -g support.
packages/​platform-apple/​src/​os/​macos/​helper.ts Serializes screen captures globally.
packages/​platform-apple/​src/​os/​macos/​helper.test.ts Tests capture serialization.
packages/​platform-apple/​src/​os/​macos/​apps.ts Propagates background-open options.
packages/​platform-apple/​src/​os/​macos/​apps.test.ts Verifies macOS open arguments.
packages/​platform-apple/​src/​lifecycle.ts Forwards background launch behavior.
packages/​platform-apple/​src/​interactor.ts Passes background options to launch.
packages/​platform-apple/​src/​core/​tool-provider-types.ts Extends host-provider interfaces.
packages/​platform-apple/​src/​core/​app-launch.ts Extends Apple launch options.
packages/​contracts/​src/​interactor-types.ts Adds the interactor background option.
packages/​contracts/​src/​application-lifecycle-runtime.ts Adds runtime background semantics.
packages/​contracts/​src/​application-lifecycle-interaction.ts Propagates background lifecycle input.
docs/​adr/​README.md Registers ADR 0034.
docs/​adr/​0034-macos-per-app-claims.md Records the claim architecture.
docs/​adr/​0031-macos-native-app-backend.md Links native behavior to ADR 0034.
docs/​adr/​0007-remote-device-leases.md Documents lease/app-claim interaction.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

for (const entry of inspectDeviceClaims({})) {
const scanned = entry.claim;
if (!scanned?.app || !scanned.deviceKey.startsWith(appKeyPrefix)) continue;
if (isClaimOwnedByThisDaemon(scanned, params.stateDir, owner)) continue;
req,
device,
sessionStore,
openerApp: admittedLeaseApp(req.internal?.admittedLease) ?? app,

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

16 issues found across 34 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/daemon/device/device-claims.ts">

<violation number="1" location="src/daemon/device/device-claims.ts:229">
P2: The doc comment says a transient command is covered by this daemon's own app claims, but `acquireTransientDeviceClaim` only checks the whole-device claim file. On macOS, a transient command while this daemon holds an app claim writes a whole-Mac `transient:<command>` claim next to it. Either add an app-claim coverage check in `acquireTransientDeviceClaim`, or correct the comment to match the behavior.</violation>

<violation number="2" location="src/daemon/device/device-claims.ts:240">
P1: Check live sessions before allowing transient whole-device admission; this skip lets commands such as `prepare` overlap this daemon’s app-scoped session.</violation>
</file>

<file name="docs/adr/0007-remote-device-leases.md">

<violation number="1" location="docs/adr/0007-remote-device-leases.md:194">
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.</violation>

<violation number="2" location="docs/adr/0007-remote-device-leases.md:194">
P2: This overstates same-app exclusion across daemons: a `macos-app` lease takes no shared host claim, so another daemon's app claim can open the same bundle ID without seeing the leased session. Scope this guarantee to sessions in one daemon.</violation>
</file>

<file name="src/daemon/session-lifecycle/internal/session-open.ts">

<violation number="1" location="src/daemon/session-lifecycle/internal/session-open.ts:247">
P2: This check runs after preparation, which can boot or warm the target and clear runtime hints; the frame was also expired above. A rejected cross-app reopen can therefore still disrupt the existing session. Resolve the target and enforce claim scope before preparation and frame expiration.</violation>
</file>

<file name="docs/adr/0031-macos-native-app-backend.md">

<violation number="1" location="docs/adr/0031-macos-native-app-backend.md:39">
P2: This rule promises per-app claims and background `open -g` for every app session, but those behaviors apply only to native sessions holding a per-app claim; unresolved targets and XCTest sessions retain whole-device behavior. Scope the rule to sessions holding a per-app claim.</violation>
</file>

<file name="docs/adr/0034-macos-per-app-claims.md">

<violation number="1" location="docs/adr/0034-macos-per-app-claims.md:5">
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.</violation>

<violation number="2" location="docs/adr/0034-macos-per-app-claims.md:81">
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.</violation>
</file>

<file name="website/docs/docs/commands.md">

<violation number="1" location="website/docs/docs/commands.md:351">
P2: This bullet promises cross-daemon app concurrency and same-app `DEVICE_IN_USE` without caveat. Cross-daemon exclusion only holds when every daemon sharing the claims directory runs the same version (ADR 0034 support boundary). Add that constraint to this bullet.</violation>

<violation number="2" location="website/docs/docs/commands.md:351">
P2: This promises `open --wait` for same-app sessions across daemons, but it waits only for session contention within the daemon; a foreign daemon's claim is refused immediately. Clarify that only same-daemon session conflicts can wait.</violation>
</file>

<file name="src/daemon/__tests__/open-device-contention-wait.test.ts">

<violation number="1" location="src/daemon/__tests__/open-device-contention-wait.test.ts:352">
P3: `openClaiming` copies the conflict decision from `findNewSessionDeviceConflict` instead of exercising it. It drops the device-id filter and the refusal builder, so these tests would keep passing if the production open's conflict logic changed. Consider exporting the production conflict lookup and calling it here.</violation>

<violation number="2" location="src/daemon/__tests__/open-device-contention-wait.test.ts:365">
P3: No test covers an app-scoped opener waiting behind a whole-Mac holder. The PR says whole-Mac and per-app claims exclude each other in both directions, so add that case to this matrix.</violation>
</file>

<file name="src/daemon/device/__tests__/device-claims.test.ts">

<violation number="1" location="src/daemon/device/__tests__/device-claims.test.ts:864">
P3: This same-app assertion would pass for any conflict. Also assert that the conflict comes from the seeded `com.example.one` claim, as the whole-Mac test does.</violation>
</file>

<file name="packages/platform-apple/src/os/macos/helper.ts">

<violation number="1" location="packages/platform-apple/src/os/macos/helper.ts:585">
P2: The screenshot's AbortSignal is only forwarded into the inner helper run, not into the lock acquisition. `withMacOsScreenCaptureLock` waits up to `MACOS_HELPER_TIMEOUT_MS + 5_000` (35s) for contention via host-kit's `acquireProcessLock`, which takes no signal and polls by sleeping, so a cancelled screenshot stays blocked for the whole wait instead of aborting promptly (previously the signal reached the helper directly). It then either runs the helper against an already-aborted signal or fails with a lock timeout. Consider letting the caller abort the lock wait, e.g. accepting an `AbortSignal` in `withMacOsScreenCaptureLock` and racing the acquisition poll against it (which needs a host-kit signal-aware acquire).</violation>

<violation number="2" location="packages/platform-apple/src/os/macos/helper.ts:597">
P3: This re-implements `resolveDeviceClaimRoot()` from `src/daemon/device/device-claim-paths.ts`. The two copies can drift, and the lock only lands in the claims directory by convention. Reuse one resolver, or move it to a shared package both sides can import.</violation>
</file>

<file name="src/daemon/session-lifecycle/internal/session-open-execution.ts">

<violation number="1" location="src/daemon/session-lifecycle/internal/session-open-execution.ts:420">
P2: `open --wait` queues only on sessions visible in this daemon's in-memory store. A per-app or whole-Mac claim held by a foreign daemon's claims file surfaces as a file conflict inside `acquireDeviceClaim`, where `reportOpenDeviceConflict` is never called, so the open refuses immediately and the wait budget buys nothing. Thread the wait deferral into the file-conflict return path (or have the wait's holder consult the claim files) so a foreign-held app waits like a held session.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

for (const entry of inspectDeviceClaims({})) {
const scanned = entry.claim;
if (!scanned?.app || !scanned.deviceKey.startsWith(appKeyPrefix)) continue;
if (isClaimOwnedByThisDaemon(scanned, params.stateDir, owner)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Check live sessions before allowing transient whole-device admission; this skip lets commands such as prepare overlap this daemon’s app-scoped session.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/daemon/device/device-claims.ts, line 240:

<comment>Check live sessions before allowing transient whole-device admission; this skip lets commands such as `prepare` overlap this daemon’s app-scoped session.</comment>

<file context>
@@ -175,6 +221,40 @@ async function claimHeldDevice(params: {
+  for (const entry of inspectDeviceClaims({})) {
+    const scanned = entry.claim;
+    if (!scanned?.app || !scanned.deviceKey.startsWith(appKeyPrefix)) continue;
+    if (isClaimOwnedByThisDaemon(scanned, params.stateDir, owner)) continue;
+    const conflict = await withDeviceClaimLock(scanned.deviceKey, async () => {
+      const current = inspectDeviceClaimFile(resolveDeviceClaimPath(scanned.deviceKey));
</file context>

* device key's lock, which every app claim acquisition also holds, so no app claim appears behind
* the scan. An app claim whose owner provably cannot release it is settled like any stale claim;
* any other foreign app claim is the conflict. This daemon's own app claims are its session
* store's business, and a transient command is covered by them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The doc comment says a transient command is covered by this daemon's own app claims, but acquireTransientDeviceClaim only checks the whole-device claim file. On macOS, a transient command while this daemon holds an app claim writes a whole-Mac transient:<command> claim next to it. Either add an app-claim coverage check in acquireTransientDeviceClaim, or correct the comment to match the behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/daemon/device/device-claims.ts, line 229:

<comment>The doc comment says a transient command is covered by this daemon's own app claims, but `acquireTransientDeviceClaim` only checks the whole-device claim file. On macOS, a transient command while this daemon holds an app claim writes a whole-Mac `transient:<command>` claim next to it. Either add an app-claim coverage check in `acquireTransientDeviceClaim`, or correct the comment to match the behavior.</comment>

<file context>
@@ -175,6 +221,40 @@ async function claimHeldDevice(params: {
+ * device key's lock, which every app claim acquisition also holds, so no app claim appears behind
+ * the scan. An app claim whose owner provably cannot release it is settled like any stale claim;
+ * any other foreign app claim is the conflict. This daemon's own app claims are its session
+ * store's business, and a transient command is covered by them.
+ */
+async function settleForeignAppClaims(
</file context>

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.

Copy link
Copy Markdown
Contributor

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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/adr/0007-remote-device-leases.md, line 194:

<comment>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.</comment>

<file context>
@@ -189,8 +189,9 @@ app for every request but `open` and the `batch` envelope, so a
-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.
 
 These rules bind a request that names the lease or runs in its session. A daemon policy
</file context>
Suggested change
beside the host's own sessions. A session on the same app does.
beside the host's app-claimed sessions on other apps. A whole-Mac session in the same daemon, or a session on the same app, does conflict.

foreground: false,
});
if (details.type === 'response') return { type: 'response', response: details.response };
const outsideAppClaim = reopenOutsideAppClaim({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This check runs after preparation, which can boot or warm the target and clear runtime hints; the frame was also expired above. A rejected cross-app reopen can therefore still disrupt the existing session. Resolve the target and enforce claim scope before preparation and frame expiration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/daemon/session-lifecycle/internal/session-open.ts, line 247:

<comment>This check runs after preparation, which can boot or warm the target and clear runtime hints; the frame was also expired above. A rejected cross-app reopen can therefore still disrupt the existing session. Resolve the target and enforce claim scope before preparation and frame expiration.</comment>

<file context>
@@ -243,6 +244,12 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise<Sessi
       foreground: false,
     });
     if (details.type === 'response') return { type: 'response', response: details.response };
+    const outsideAppClaim = reopenOutsideAppClaim({
+      session: preparedSession,
+      surface: surfaceResult,
</file context>

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`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This rule promises per-app claims and background open -g for every app session, but those behaviors apply only to native sessions holding a per-app claim; unresolved targets and XCTest sessions retain whole-device behavior. Scope the rule to sessions holding a per-app claim.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/adr/0031-macos-native-app-backend.md, line 39:

<comment>This rule promises per-app claims and background `open -g` for every app session, but those behaviors apply only to native sessions holding a per-app claim; unresolved targets and XCTest sessions retain whole-device behavior. Scope the rule to sessions holding a per-app claim.</comment>

<file context>
@@ -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`,
+   leaving the frontmost app in front ([ADR 0034](0034-macos-per-app-claims.md)).
 
</file context>
Suggested change
7. An app session claims only its app, and `open` launches or reopens that app with `open -g`,
7. A native macOS session holding a per-app claim claims only its app, and `open` launches or reopens that app with `open -g`,


## Status

Accepted (2026-10-08). Applies to the native macOS app backend (ADR 0031) only. Numbered 0034

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/adr/0034-macos-per-app-claims.md, line 5:

<comment>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.</comment>

<file context>
@@ -0,0 +1,83 @@
+
+## Status
+
+Accepted (2026-10-08). Applies to the native macOS app backend (ADR 0031) only. Numbered 0034
+because 0032 and 0033 exist on main.
+
</file context>
Suggested change
Accepted (2026-10-08). Applies to the native macOS app backend (ADR 0031) only. Numbered 0034
Accepted (2026-10-08). Applies to the native macOS app backend (ADR 0031) only.

};
}

for (const { opener, openerApp, waits } of [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: No test covers an app-scoped opener waiting behind a whole-Mac holder. The PR says whole-Mac and per-app claims exclude each other in both directions, so add that case to this matrix.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/daemon/__tests__/open-device-contention-wait.test.ts, line 365:

<comment>No test covers an app-scoped opener waiting behind a whole-Mac holder. The PR says whole-Mac and per-app claims exclude each other in both directions, so add that case to this matrix.</comment>

<file context>
@@ -316,3 +317,107 @@ async function waitUntil(start: () => Promise<void>, budgetMs: number): Promise<
+  };
+}
+
+for (const { opener, openerApp, waits } of [
+  { opener: 'a whole-Mac opener', openerApp: undefined, waits: true },
+  { opener: 'a same-app opener', openerApp: { bundleId: 'COM.example.one' }, waits: true },
</file context>

* An open that resolves its claim under the locks, as the new-session open does: it reports a
* conflicting session to the wait and refuses, or opens.
*/
function openClaiming(req: DaemonRequest, store: SessionStore, openerApp?: { bundleId: string }) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: openClaiming copies the conflict decision from findNewSessionDeviceConflict instead of exercising it. It drops the device-id filter and the refusal builder, so these tests would keep passing if the production open's conflict logic changed. Consider exporting the production conflict lookup and calling it here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/daemon/__tests__/open-device-contention-wait.test.ts, line 352:

<comment>`openClaiming` copies the conflict decision from `findNewSessionDeviceConflict` instead of exercising it. It drops the device-id filter and the refusal builder, so these tests would keep passing if the production open's conflict logic changed. Consider exporting the production conflict lookup and calling it here.</comment>

<file context>
@@ -316,3 +317,107 @@ async function waitUntil(start: () => Promise<void>, budgetMs: number): Promise<
+ * An open that resolves its claim under the locks, as the new-session open does: it reports a
+ * conflicting session to the wait and refuses, or opens.
+ */
+function openClaiming(req: DaemonRequest, store: SessionStore, openerApp?: { bundleId: string }) {
+  return async () => {
+    const conflict = store
</file context>

stateDir: root,
app: { bundleId: 'com.Example.One' },
});
assert.equal(same.status, 'conflict');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This same-app assertion would pass for any conflict. Also assert that the conflict comes from the seeded com.example.one claim, as the whole-Mac test does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/daemon/device/__tests__/device-claims.test.ts, line 864:

<comment>This same-app assertion would pass for any conflict. Also assert that the conflict comes from the seeded `com.example.one` claim, as the whole-Mac test does.</comment>

<file context>
@@ -785,3 +790,216 @@ test('an allocator-held claim conflicts with an ordinary acquire, is never recon
+    stateDir: root,
+    app: { bundleId: 'com.Example.One' },
+  });
+  assert.equal(same.status, 'conflict');
+});
+
</file context>
Suggested change
assert.equal(same.status, 'conflict');
assert.equal(same.status, 'conflict');
if (same.status !== 'conflict') return;
assert.equal(same.conflict.claim?.app?.bundleId, 'com.example.one');

* the store every daemon on the host already coordinates through.
*/
async function withMacOsScreenCaptureLock<T>(task: () => Promise<T>): Promise<T> {
const lockRoot =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This re-implements resolveDeviceClaimRoot() from src/daemon/device/device-claim-paths.ts. The two copies can drift, and the lock only lands in the claims directory by convention. Reuse one resolver, or move it to a shared package both sides can import.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-apple/src/os/macos/helper.ts, line 597:

<comment>This re-implements `resolveDeviceClaimRoot()` from `src/daemon/device/device-claim-paths.ts`. The two copies can drift, and the lock only lands in the claims directory by convention. Reuse one resolver, or move it to a shared package both sides can import.</comment>

<file context>
@@ -581,5 +582,33 @@ export async function runMacOsScreenshotAction(
+ * the store every daemon on the host already coordinates through.
+ */
+async function withMacOsScreenCaptureLock<T>(task: () => Promise<T>): Promise<T> {
+  const lockRoot =
+    readHostEnvironmentVariable('AGENT_DEVICE_CLAIMS_DIR')?.trim() ||
+    path.join(hostHomeDirectory(), '.agent-device', 'device-claims');
</file context>

@janicduplessis

Copy link
Copy Markdown
Contributor Author

Thanks. Pushed four follow-ups on top of 88b9ead.

  • 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.

Copilot AI balanced review requested due to automatic review settings October 9, 2026 02:52

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 10 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/daemon/__tests__/open-device-contention-wait.test.ts">

<violation number="1" location="src/daemon/__tests__/open-device-contention-wait.test.ts:437">
P2: This regression test copies the request inside `task`, after the spend is already written, so the old code would pass it too. Take the copy right after `beginWait(...)`, before `runWhenDeviceIsUnheld`, and read the spend from that copy so the test fails if the spend object is replaced.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

})!;
await wait.waitForDeviceOutsideLocks();

let admitted = req;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This regression test copies the request inside task, after the spend is already written, so the old code would pass it too. Take the copy right after beginWait(...), before runWhenDeviceIsUnheld, and read the spend from that copy so the test fails if the spend object is replaced.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/daemon/__tests__/open-device-contention-wait.test.ts, line 437:

<comment>This regression test copies the request inside `task`, after the spend is already written, so the old code would pass it too. Take the copy right after `beginWait(...)`, before `runWhenDeviceIsUnheld`, and read the spend from that copy so the test fails if the spend object is replaced.</comment>

<file context>
@@ -421,3 +421,30 @@ test('a whole-Mac opener whose budget runs out beside an app session reports the
+  })!;
+  await wait.waitForDeviceOutsideLocks();
+
+  let admitted = req;
+  const running = wait.runWhenDeviceIsUnheld({
+    acquireLocks: lockTrace({}).acquireLocks,
</file context>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Core exclusion guarantees still have unresolved cross-daemon arbitration and app-scope escape defects.

2 open findings
Previously missed (1)

In code that hasn't changed since last review

Medium severity Bind launch URLs to the claimed app bundle

packages/​platform-apple/​src/​lifecycle.ts:268

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.

🧠 Review effort: Balanced

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member

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.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS: let native-backend app sessions claim one app instead of the whole Mac

3 participants