Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
66cab9d
refactor(daemon): migrate clipboard onto request-bound runtimes (R55)
claude Aug 24, 2026
09bbd1c
refactor(daemon): migrate app-switcher onto request-bound runtimes (R56)
claude Aug 24, 2026
42ac0b7
refactor(daemon): migrate trigger-app-event onto request-bound runtim…
claude Aug 24, 2026
c5f2ca3
refactor(daemon): migrate settings onto request-bound runtimes and re…
claude Aug 24, 2026
323f8ad
refactor(daemon): migrate alert, react-native and capabilities onto f…
claude Aug 24, 2026
8c1199e
fix(daemon): restore two settings/alert sequences the migration had s…
claude Aug 24, 2026
5a24fd3
fix(daemon): address adversarial review of the Wave 6 cutovers
claude Aug 24, 2026
7d58a45
fix(apple): type the alert-absence retry instead of matching error prose
claude Aug 24, 2026
6ed072f
fix(runtime): admit clipboard and provider operations from what execu…
claude Aug 25, 2026
e3b13c8
refactor(layering): split the Wave 6 cutover rows into a sibling module
claude Aug 25, 2026
8fde518
fix(android): never fabricate clipboard availability from a failed probe
claude Aug 25, 2026
d3635c7
refactor(contracts): declare each interactor operation once
claude Aug 25, 2026
efd761c
fix(android): let only a clean adb exit prove clipboard support
claude Aug 25, 2026
59d1853
fix(android): never read adb's refusal prose out of the clipboard's c…
claude Aug 25, 2026
d81c86c
fix(cli): bring the workflow help card back under its size budget
claude Aug 25, 2026
ad02543
test(cli): raise the workflow help-card budget to 9100
claude Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,14 @@ struct AgentDeviceMacOSHelper {
let surface = optionValue(arguments: Array(arguments.dropFirst()), name: "--surface")
let app = try resolveTargetApplication(bundleId: bundleId, surface: surface)
guard let alertElement = findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) else {
// `reason` is the typed channel the host retries on; the message is for humans only.
throw HelperError.commandFailed(
"alert not found",
details: ["bundleId": app.bundleIdentifier ?? "", "appName": app.localizedName ?? ""]
details: [
"reason": "alert-not-found",
"bundleId": app.bundleIdentifier ?? "",
"appName": app.localizedName ?? "",
]
)
}
let buttons = collectButtons(root: alertElement)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2190,7 +2190,12 @@ extension RunnerTests {
Self.alertCommandTimeout(timeoutMs: command.timeoutMs)
)
guard let alert = resolveAlert(app: activeApp, deadline: deadline) else {
return Response(ok: false, error: ErrorPayload(message: "alert not found"))
// Typed so the host retries on absence alone: a transport or runner failure carries no
// code and must not be mistaken for "no alert yet" (ALERT_NOT_FOUND_RUNNER_CODE).
return Response(
ok: false,
error: ErrorPayload(code: "ALERT_NOT_FOUND", message: "alert not found")
)
}
return handleAlert(alert, action: action, deadline: deadline)
case .gesture:
Expand Down
28 changes: 28 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
"types": "./src/alert-contract.ts",
"default": "./src/alert-contract.ts"
},
"./alert-runtime": {
"types": "./src/alert-runtime.ts",
"default": "./src/alert-runtime.ts"
},
"./android-clipboard-support": {
"types": "./src/android-clipboard-support.ts",
"default": "./src/android-clipboard-support.ts"
},
"./android-input-ownership": {
"types": "./src/android-input-ownership.ts",
"default": "./src/android-input-ownership.ts"
Expand All @@ -35,6 +43,10 @@
"types": "./src/app-deployment-runtime-plan.ts",
"default": "./src/app-deployment-runtime-plan.ts"
},
"./app-event-runtime": {
"types": "./src/app-event-runtime.ts",
"default": "./src/app-event-runtime.ts"
},
"./app-inventory-runtime": {
"types": "./src/app-inventory-runtime.ts",
"default": "./src/app-inventory-runtime.ts"
Expand All @@ -47,6 +59,10 @@
"types": "./src/app-state-runtime.ts",
"default": "./src/app-state-runtime.ts"
},
"./app-switcher-runtime": {
"types": "./src/app-switcher-runtime.ts",
"default": "./src/app-switcher-runtime.ts"
},
"./apple-multitouch-support": {
"types": "./src/apple-multitouch-support.ts",
"default": "./src/apple-multitouch-support.ts"
Expand Down Expand Up @@ -95,6 +111,10 @@
"types": "./src/facades/client.ts",
"default": "./src/facades/client.ts"
},
"./clipboard-runtime": {
"types": "./src/clipboard-runtime.ts",
"default": "./src/clipboard-runtime.ts"
},
"./command": {
"types": "./src/facades/command.ts",
"default": "./src/facades/command.ts"
Expand Down Expand Up @@ -187,6 +207,10 @@
"types": "./src/keyboard-runtime.ts",
"default": "./src/keyboard-runtime.ts"
},
"./local-interactor-operation-set": {
"types": "./src/local-interactor-operation-set.ts",
"default": "./src/local-interactor-operation-set.ts"
},
"./logs-runtime-plan": {
"types": "./src/logs-runtime-plan.ts",
"default": "./src/logs-runtime-plan.ts"
Expand Down Expand Up @@ -295,6 +319,10 @@
"types": "./src/settings.ts",
"default": "./src/settings.ts"
},
"./settings-runtime": {
"types": "./src/settings-runtime.ts",
"default": "./src/settings-runtime.ts"
},
"./snapshot": {
"types": "./src/facades/snapshot.ts",
"default": "./src/facades/snapshot.ts"
Expand Down
10 changes: 10 additions & 0 deletions packages/contracts/src/alert-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ export const ALERT_POLL_INTERVAL_MS = 300;
export const DEFAULT_ALERT_TIMEOUT_MS = 10_000;
export const ALERT_ACTION_RETRY_MS = 2_000;

/**
* The one alert failure the family retries on: the backend looked and there was no alert *yet*.
* Both Apple backends state it in typed form — the XCTest runner as this `ErrorPayload.code`
* (surfacing as `details.runnerErrorCode`, like `RUNNER_BUSY`, without changing the wire error
* code), the macOS helper as `details.reason`. Retry and the fallback hint key on these, never on
* the message text: a transport, runner or helper failure must never read as an absent alert.
*/
export const ALERT_NOT_FOUND_RUNNER_CODE = 'ALERT_NOT_FOUND';
export const ALERT_NOT_FOUND_REASON = 'alert-not-found';

export const ALERT_ACTIONS = ['get', 'accept', 'dismiss', 'wait'] as const;
export type AlertAction = (typeof ALERT_ACTIONS)[number];

Expand Down
142 changes: 142 additions & 0 deletions packages/contracts/src/alert-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { expect, test, vi } from 'vitest';
import { alertRuntimeOperationFacts, bindAlertLeg } from './alert-runtime.ts';
import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts';
import type { AlertInteractorOptions, Interactor } from './interactor-types.ts';

const device = {
platform: 'apple',
appleOs: 'ios',
id: 'sim-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
booted: true,
} as const;

// The composition the interactor catalog performs, spelled out so each assertion below
// still exercises one facet executor reached through one interactor source.
const bindLocalAlertReadInteractor = (params: {
device: typeof device;
signal: AbortSignal;
resolveInteractor: any;
}) => bindAlertLeg('readAlert', params.signal, localInteractorSource(params));
const bindLocalAlertWaitInteractor = (params: {
device: typeof device;
signal: AbortSignal;
resolveInteractor: any;
}) => bindAlertLeg('awaitAlert', params.signal, localInteractorSource(params));
const bindLocalAlertAcceptInteractor = (params: {
device: typeof device;
signal: AbortSignal;
resolveInteractor: any;
}) => bindAlertLeg('acceptAlert', params.signal, localInteractorSource(params));
const bindLocalAlertDismissInteractor = (params: {
device: typeof device;
signal: AbortSignal;
resolveInteractor: any;
}) => bindAlertLeg('dismissAlert', params.signal, localInteractorSource(params));
const bindProviderAlertAcceptInteractor = (params: {
device: typeof device;
signal: AbortSignal;
resolveInteractor: any;
}) =>
bindAlertLeg(
'acceptAlert',
params.signal,
providerInteractorSource({ ...params, operation: 'alert accept' }),
);

test('builds the exact alert operation fact catalog', () => {
const read = { available: true } as const;
const wait = { available: false, reason: 'owner-capability-missing' } as const;
const accept = { available: true } as const;
const dismiss = { available: true } as const;

expect(alertRuntimeOperationFacts({ read, wait, accept, dismiss })).toEqual({
readAlert: read,
awaitAlert: wait,
acceptAlert: accept,
dismissAlert: dismiss,
});
});

// The whole input is the window this request allows and the session's own target; the owner
// decides how to spend the window and which backend answers.
test('each local leg forwards the window and the session target to its own owner method', async () => {
const legs = {
readAlert: vi.fn(async () => ({ title: 'Camera Access' })),
awaitAlert: vi.fn(async () => ({ title: 'Camera Access' })),
acceptAlert: vi.fn(async () => ({ accepted: true })),
dismissAlert: vi.fn(async () => ({ dismissed: true })),
};
const resolveInteractor = vi.fn(async () => legs as unknown as Interactor);
const signal = new AbortController().signal;
const params = { device, signal, resolveInteractor };
const input = {
timeoutMs: 37,
appBundleId: 'com.example.app',
surface: 'app' as const,
execution: { logPath: '/tmp/daemon.log', requestId: 'alert-1' },
};

await bindLocalAlertReadInteractor(params).readAlert(input);
await bindLocalAlertWaitInteractor(params).awaitAlert(input);
await bindLocalAlertAcceptInteractor(params).acceptAlert(input);
await bindLocalAlertDismissInteractor(params).dismissAlert(input);

const expectedOptions = { timeoutMs: 37, appBundleId: 'com.example.app', surface: 'app' };
expect(legs.readAlert).toHaveBeenCalledWith(expectedOptions);
expect(legs.awaitAlert).toHaveBeenCalledWith(expectedOptions);
expect(legs.acceptAlert).toHaveBeenCalledWith(expectedOptions);
expect(legs.dismissAlert).toHaveBeenCalledWith(expectedOptions);
expect(resolveInteractor).toHaveBeenLastCalledWith(device, {
logPath: '/tmp/daemon.log',
requestId: 'alert-1',
appBundleId: 'com.example.app',
signal,
});
});

// A frontmost-app session carries no bundle at all, and the option object must not invent one.
test('an absent target field never reaches the owner as an explicit undefined', async () => {
const readAlert = vi.fn(async (_options?: AlertInteractorOptions) => ({}));
const operations = bindLocalAlertReadInteractor({
device,
signal: new AbortController().signal,
resolveInteractor: async () => ({ readAlert }) as unknown as Interactor,
});

await operations.readAlert({ surface: 'frontmost-app' });

expect(readAlert).toHaveBeenCalledWith({ surface: 'frontmost-app' });
expect(Object.keys(readAlert.mock.calls[0]?.[0] ?? {})).toEqual(['surface']);
});

test('a provider binding fails closed when its exact owner exposes no interactor', async () => {
const operations = bindProviderAlertAcceptInteractor({
device,
signal: new AbortController().signal,
resolveInteractor: () => undefined,
});

await expect(operations.acceptAlert({})).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id },
});
});

test('an already-cancelled request never resolves an interactor', async () => {
const controller = new AbortController();
controller.abort();
const readAlert = vi.fn(async () => ({}));
const resolveInteractor = vi.fn(async () => ({ readAlert }) as unknown as Interactor);

const operations = bindLocalAlertReadInteractor({
device,
signal: controller.signal,
resolveInteractor,
});

await expect(operations.readAlert({})).rejects.toThrow();
expect(resolveInteractor).not.toHaveBeenCalled();
expect(readAlert).not.toHaveBeenCalled();
});
118 changes: 118 additions & 0 deletions packages/contracts/src/alert-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { AlertInteractorOptions, Interactor, RunnerContext } from './interactor-types.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';
import type { SessionSurface } from './session-surface.ts';
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';

/**
* Neutral intent for one alert leg. Nothing command-shaped travels here: the daemon has already
* parsed the subcommand, chosen which of the four operations to bind, and turned the CLI's
* optional timeout positional into a millisecond window.
*
* `appBundleId` and `surface` are the session's own target, forwarded as the pair the owner
* needs — a macOS frontmost-app session deliberately carries no bundle, and collapsing the two
* into one field would lose that.
*/
export type AlertRuntimeInput = Readonly<{
/** The whole window this request allows the owner; absent means the owner's own default. */
timeoutMs?: number;
appBundleId?: string;
surface?: SessionSurface;
/** Same runner metadata a capture needs; reuses that type rather than restating it. */
execution?: SnapshotRuntimeExecution;
}>;

/**
* Owners answer with their own alert payload — Android's discriminated `alertStatus`/`alertWait`/
* `alertHandled` records, the XCTest runner's alert fields, the macOS helper's. The daemon
* composes the response around whichever it gets, exactly as the retired leaf did.
*/
export type AlertReadRuntimeOperations = Readonly<{
readAlert(input: AlertRuntimeInput): Promise<Record<string, unknown>>;
}>;

export type AlertWaitRuntimeOperations = Readonly<{
awaitAlert(input: AlertRuntimeInput): Promise<Record<string, unknown>>;
}>;

export type AlertAcceptRuntimeOperations = Readonly<{
acceptAlert(input: AlertRuntimeInput): Promise<Record<string, unknown>>;
}>;

export type AlertDismissRuntimeOperations = Readonly<{
dismissAlert(input: AlertRuntimeInput): Promise<Record<string, unknown>>;
}>;

export type AlertRuntimeOperations = AlertReadRuntimeOperations &
AlertWaitRuntimeOperations &
AlertAcceptRuntimeOperations &
AlertDismissRuntimeOperations;

export type AlertRuntimeOperationFacts = Readonly<{
readAlert: RuntimeOperationFact;
awaitAlert: RuntimeOperationFact;
acceptAlert: RuntimeOperationFact;
dismissAlert: RuntimeOperationFact;
}>;

/**
* Four cells rather than one, because the daemon binds exactly the leg the parsed subcommand
* names (ADR 0019 §9). No owner today observes an alert it cannot act on, so every owner passes
* the same fact four times — but a partial owner would then refuse only the legs it lacks, rather
* than taking the whole command down with it.
*/
export function alertRuntimeOperationFacts(
input: Readonly<{
read: RuntimeOperationFact;
wait: RuntimeOperationFact;
accept: RuntimeOperationFact;
dismiss: RuntimeOperationFact;
}>,
): AlertRuntimeOperationFacts {
return Object.freeze({
readAlert: input.read,
awaitAlert: input.wait,
acceptAlert: input.accept,
dismissAlert: input.dismiss,
});
}

/**
* Captures one selected owner's interactor authority for the lifetime of a request binding. The
* owner is already chosen by the time a binder is called, so each entry point supplies its own
* resolution and this holds only what all four legs share: the runner context and the target.
*/
async function resolveAlertInteractor(
signal: AbortSignal,
resolveInteractor: (runner: RunnerContext) => Promise<Interactor>,
input: AlertRuntimeInput,
): Promise<Interactor> {
signal.throwIfAborted();
return await resolveInteractor({
...input.execution,
appBundleId: input.appBundleId,
signal,
});
}

function alertInteractorOptions(input: AlertRuntimeInput): AlertInteractorOptions {
return {
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
...(input.appBundleId === undefined ? {} : { appBundleId: input.appBundleId }),
...(input.surface === undefined ? {} : { surface: input.surface }),
};
}

type AlertLeg = 'readAlert' | 'awaitAlert' | 'acceptAlert' | 'dismissAlert';

export function bindAlertLeg<Leg extends AlertLeg>(
leg: Leg,
signal: AbortSignal,
resolveInteractor: (runner: RunnerContext) => Promise<Interactor>,
): Readonly<Record<Leg, (input: AlertRuntimeInput) => Promise<Record<string, unknown>>>> {
return Object.freeze({
[leg]: async (input: AlertRuntimeInput) => {
const interactor = await resolveAlertInteractor(signal, resolveInteractor, input);
return await interactor[leg](alertInteractorOptions(input));
},
}) as Readonly<Record<Leg, (input: AlertRuntimeInput) => Promise<Record<string, unknown>>>>;
}
13 changes: 13 additions & 0 deletions packages/contracts/src/android-clipboard-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* What an Android build's clipboard shell service answered when the owner asked.
*
* Three states, not two, because "we could not ask" is not "it works". `cmd clipboard` has no
* shell implementation on every build, and admission has to distinguish a build that said so from
* a probe that never got an answer — equating unknown with supported is how `capabilities` comes
* to advertise a clipboard that execution then refuses.
*
* Contracts carry the typed verdict only. Turning raw adb output into it is Android tool
* knowledge and stays with the Android owner (ADR 0019: platform output parsing belongs to the
* owning family, never to shared vocabulary).
*/
export type AndroidClipboardShellSupport = 'supported' | 'unsupported' | 'probe-failed';
Loading
Loading