feat(daemon): [RFC] prototype foreground-attach convenience (#1670)

Prototype `open --foreground`: on a fresh session with no app argument,
auto-resolves the target from the sole booted iOS simulator's sole
foreground app (reusing the exact same ambiguity-detection probe that
enriches the SESSION_NOT_FOUND hint), then attaches the initial
interactive snapshot to the response by composing the existing
snapshot-runtime dispatch. Collapses the documented 3-call
snapshot-fails -> read-hint -> open -> snapshot-succeeds dance into a
single call for the unambiguous case, while failing closed
(AMBIGUOUS_MATCH) with no guessing otherwise.

First-pass RFC, not reviewed — see PR body for the design tradeoff
writeup, live before/after evidence, and scoped-out follow-ups.
This commit is contained in:
Michał Pierzchała
2026-08-07 13:17:13 +02:00
committed by GitHub
parent 4f510ebd18
commit cc943400a9
13 changed files with 501 additions and 29 deletions
+7
View File
@@ -116,6 +116,13 @@ export type CliFlags = CloudProviderProfileFields &
saveScript?: boolean | string;
shutdown?: boolean;
relaunch?: boolean;
/**
* RFC prototype (open --foreground, branch claude/observe-foreground): resolve the
* app target from the sole booted iOS simulator's sole running app instead of
* requiring an explicit app argument. Fails closed (AMBIGUOUS_MATCH) unless the
* environment is unambiguous.
*/
foreground?: boolean;
surface?: SessionSurface;
headless?: boolean;
restart?: boolean;
+17
View File
@@ -45,6 +45,13 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
launchConsole?: string;
launchArgs?: string[];
relaunch?: boolean;
/**
* RFC prototype (branch claude/observe-foreground): resolve the app target from the
* sole booted iOS simulator's sole running app instead of an explicit app
* argument, and return an initial interactive snapshot alongside the open result.
* Fails closed (AMBIGUOUS_MATCH) unless the environment is unambiguous.
*/
foreground?: boolean;
saveScript?: boolean | string;
/** #1258: overwrite an existing --save-script target instead of refusing. Alias: --overwrite. */
force?: boolean;
@@ -67,6 +74,16 @@ export type AppOpenResult = {
startup?: StartupPerfSample;
runtime?: SessionRuntimeHints;
device?: AgentDeviceSessionDevice;
/**
* RFC prototype (open --foreground, branch claude/observe-foreground): the initial
* interactive snapshot captured immediately after open, composed from the same
* snapshot-runtime dispatch `agent-device snapshot -i` uses. Present only when
* `--foreground` triggered the auto-resolve path. Left loosely typed for this
* prototype rather than reusing `CaptureSnapshotResult` end to end (see PR
* follow-ups) since the daemon-side composition does not attach client-only
* fields like `identifiers`.
*/
snapshot?: Record<string, unknown>;
identifiers: AgentDeviceIdentifiers;
};
+5
View File
@@ -340,6 +340,11 @@ function summarizeProviderScenarioFlagExclusions() {
owner: 'handler and Android platform unit tests',
keys: ['headless', 'testIme'],
},
{
name: 'open foreground auto-resolution (RFC prototype)',
owner: 'daemon session-open-foreground handler unit tests',
keys: ['foreground'],
},
{
name: 'Apple simulator screenshot rendering options',
owner: 'iOS platform and screenshot-diff runtime tests',
+5
View File
@@ -279,6 +279,11 @@ export function createAgentDeviceClient(
startup: normalizeStartupSample(data.startup),
runtime: normalizeRuntimeHints(data.runtime),
device,
// RFC prototype (open --foreground): only present when the daemon's
// foreground-attach composition captured an initial snapshot.
...(data.snapshot && typeof data.snapshot === 'object'
? { snapshot: data.snapshot as Record<string, unknown> }
: {}),
identifiers: {
session,
deviceId: device?.id,
@@ -273,6 +273,14 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
usageLabel: '--relaunch',
usageDescription: 'open: terminate app process before launching it',
},
{
key: 'foreground',
names: ['--foreground'],
type: 'boolean',
usageLabel: '--foreground',
usageDescription:
"[RFC] open: resolve app + return an initial snapshot from the sole booted iOS simulator's sole running app",
},
{
key: 'restart',
names: ['--restart'],
+5
View File
@@ -50,6 +50,9 @@ const openCommandMetadata = defineFieldCommandMetadata(
'Launch arguments forwarded verbatim to the platform launch command.',
),
relaunch: booleanField('Force relaunch.'),
foreground: booleanField(
"[RFC prototype] On a fresh session with no app argument, resolve the target from the sole booted iOS simulator's sole running app and include an initial interactive snapshot in the response. Fails with AMBIGUOUS_MATCH when the environment is not unambiguous (0 or 2+ booted simulators, or no confidently-detectable running app) — never guesses.",
),
saveScript: jsonSchemaField<boolean | string>({
oneOf: [booleanSchema(), stringSchema()],
}),
@@ -130,6 +133,7 @@ const openCliSchema = {
'force',
'noRecord',
'relaunch',
'foreground',
'surface',
...METRO_RELOAD_FLAGS,
'launchUrl',
@@ -155,6 +159,7 @@ const openCliReader: CliReader = (positionals, flags) => ({
launchConsole: flags.launchConsole,
launchArgs: flags.launchArgs,
relaunch: flags.relaunch,
foreground: flags.foreground,
saveScript: flags.saveScript,
force: flags.force,
deviceHub: flags.deviceHub,
@@ -0,0 +1,217 @@
import { beforeEach, expect, test, vi } from 'vitest';
const resolveSoleForegroundIosApp = vi.hoisted(() => vi.fn());
const dispatchSnapshotViaRuntime = vi.hoisted(() => vi.fn());
vi.mock('../../ios-app-session-hint.ts', () => ({ resolveSoleForegroundIosApp }));
vi.mock('../../snapshot-runtime.ts', () => ({ dispatchSnapshotViaRuntime }));
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
import type { DaemonRequest, DaemonResponse } from '../../types.ts';
import {
composeOpenWithInitialSnapshot,
resolveForegroundOpenRequest,
} from '../session-open-foreground.ts';
function baseRequest(overrides: Partial<DaemonRequest> = {}): DaemonRequest {
return {
token: 't',
session: 'default',
command: 'open',
positionals: [],
flags: {},
...overrides,
};
}
beforeEach(() => {
resolveSoleForegroundIosApp.mockReset();
dispatchSnapshotViaRuntime.mockReset();
});
// --- resolveForegroundOpenRequest ---
test('leaves the request untouched when --foreground was not requested', async () => {
const resolution = await resolveForegroundOpenRequest({
req: baseRequest(),
hasExistingSession: false,
});
expect(resolution).toEqual({ type: 'not-requested' });
expect(resolveSoleForegroundIosApp).not.toHaveBeenCalled();
});
test('rejects --foreground against an existing session', async () => {
const resolution = await resolveForegroundOpenRequest({
req: baseRequest({ flags: { foreground: true } }),
hasExistingSession: true,
});
expect(resolution.type).toBe('response');
if (resolution.type === 'response') {
expect(resolution.response.ok).toBe(false);
if (!resolution.response.ok) {
expect(resolution.response.error.code).toBe('INVALID_ARGS');
}
}
expect(resolveSoleForegroundIosApp).not.toHaveBeenCalled();
});
test('rejects --foreground combined with an explicit app argument', async () => {
const resolution = await resolveForegroundOpenRequest({
req: baseRequest({ flags: { foreground: true }, positionals: ['com.example.demo'] }),
hasExistingSession: false,
});
expect(resolution.type).toBe('response');
if (resolution.type === 'response') {
expect(resolution.response.ok).toBe(false);
if (!resolution.response.ok) {
expect(resolution.response.error.code).toBe('INVALID_ARGS');
}
}
expect(resolveSoleForegroundIosApp).not.toHaveBeenCalled();
});
test('an unambiguous environment rewrites positionals and pins the resolved device', async () => {
const soleBootedDevice = { ...IOS_SIMULATOR, id: 'booted-1', name: 'iPhone 16' };
resolveSoleForegroundIosApp.mockResolvedValue({
device: soleBootedDevice,
app: { bundleId: 'xyz.blueskyweb.app', name: 'Bluesky' },
});
const resolution = await resolveForegroundOpenRequest({
req: baseRequest({ flags: { foreground: true, iosSimulatorDeviceSet: '/custom/set' } }),
hasExistingSession: false,
});
expect(resolution).toEqual({
type: 'resolved',
req: baseRequest({
flags: {
foreground: true,
iosSimulatorDeviceSet: '/custom/set',
udid: 'booted-1',
platform: 'ios',
},
positionals: ['xyz.blueskyweb.app'],
}),
});
expect(resolveSoleForegroundIosApp).toHaveBeenCalledWith({ simulatorSetPath: '/custom/set' });
});
test('an ambiguous environment fails closed with AMBIGUOUS_MATCH instead of guessing', async () => {
resolveSoleForegroundIosApp.mockResolvedValue(undefined);
const resolution = await resolveForegroundOpenRequest({
req: baseRequest({ flags: { foreground: true } }),
hasExistingSession: false,
});
expect(resolution.type).toBe('response');
if (resolution.type === 'response') {
expect(resolution.response.ok).toBe(false);
if (!resolution.response.ok) {
expect(resolution.response.error.code).toBe('AMBIGUOUS_MATCH');
expect(resolution.response.error.details?.reason).toBe('foreground_app_ambiguous');
expect(resolution.response.error.details?.hint).toMatch(/agent-device open <app>/);
}
}
});
test('a probe failure fails closed like ambiguity, never silently succeeds', async () => {
// The resolver owns the catch-all (see resolveSoleForegroundIosApp): a
// rejecting simctl/launchctl probe surfaces as `undefined`, which this
// handler must treat exactly like ambiguity — a hard AMBIGUOUS_MATCH, not
// a guessed target.
resolveSoleForegroundIosApp.mockResolvedValue(undefined);
const resolution = await resolveForegroundOpenRequest({
req: baseRequest({ flags: { foreground: true } }),
hasExistingSession: false,
});
expect(resolution.type).toBe('response');
if (resolution.type === 'response') {
expect(resolution.response.ok).toBe(false);
if (!resolution.response.ok) {
expect(resolution.response.error.code).toBe('AMBIGUOUS_MATCH');
}
}
});
// --- composeOpenWithInitialSnapshot ---
const okOpenResponse: DaemonResponse = { ok: true, data: { session: 'default' } };
const failedOpenResponse: DaemonResponse = {
ok: false,
error: { code: 'AMBIGUOUS_MATCH', message: 'nope' },
};
test('passes a failed open response through untouched', async () => {
const result = await composeOpenWithInitialSnapshot({
req: baseRequest({ flags: { foreground: true } }),
sessionName: 'default',
logPath: '/tmp/daemon.log',
sessionStore: {} as never,
openResponse: failedOpenResponse,
});
expect(result).toBe(failedOpenResponse);
expect(dispatchSnapshotViaRuntime).not.toHaveBeenCalled();
});
test('leaves a successful open response untouched when --foreground was not requested', async () => {
const result = await composeOpenWithInitialSnapshot({
req: baseRequest(),
sessionName: 'default',
logPath: '/tmp/daemon.log',
sessionStore: {} as never,
openResponse: okOpenResponse,
});
expect(result).toBe(okOpenResponse);
expect(dispatchSnapshotViaRuntime).not.toHaveBeenCalled();
});
test('attaches the initial snapshot by delegating to the existing snapshot runtime dispatch', async () => {
dispatchSnapshotViaRuntime.mockResolvedValue({ ok: true, data: { nodes: [], truncated: false } });
const req = baseRequest({ flags: { foreground: true }, positionals: ['xyz.blueskyweb.app'] });
const result = await composeOpenWithInitialSnapshot({
req,
sessionName: 'default',
logPath: '/tmp/daemon.log',
sessionStore: {} as never,
openResponse: okOpenResponse,
});
expect(dispatchSnapshotViaRuntime).toHaveBeenCalledWith({
req: { ...req, command: 'snapshot', positionals: [] },
sessionName: 'default',
logPath: '/tmp/daemon.log',
sessionStore: {},
});
expect(result).toEqual({
ok: true,
data: { session: 'default', snapshot: { nodes: [], truncated: false } },
});
});
test('surfaces a snapshot-capture failure instead of the open success', async () => {
const snapshotFailure: DaemonResponse = {
ok: false,
error: { code: 'COMMAND_FAILED', message: 'capture failed' },
};
dispatchSnapshotViaRuntime.mockResolvedValue(snapshotFailure);
const result = await composeOpenWithInitialSnapshot({
req: baseRequest({ flags: { foreground: true } }),
sessionName: 'default',
logPath: '/tmp/daemon.log',
sessionStore: {} as never,
openResponse: okOpenResponse,
});
expect(result).toBe(snapshotFailure);
});
@@ -0,0 +1,118 @@
import { resolveSoleForegroundIosApp } from '../ios-app-session-hint.ts';
import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts';
import type { SessionStore } from '../session-store.ts';
import type { DaemonRequest, DaemonResponse } from '../types.ts';
import { errorResponse } from './response.ts';
export type ForegroundOpenResolution =
| { type: 'not-requested' }
| { type: 'resolved'; req: DaemonRequest }
| { type: 'response'; response: DaemonResponse };
/**
* RFC prototype (branch claude/observe-foreground): `open --foreground` collapses the
* `snapshot -i` (fails) -> read hint -> `open <bundleId>` -> `snapshot -i` dance into one
* call for the common case (one booted iOS simulator, one running app).
*
* When `--foreground` is set with no explicit app argument on a fresh session, this
* auto-resolves the target via `resolveSoleForegroundIosApp` — the exact same
* ambiguity-detection probe that enriches the SESSION_NOT_FOUND hint — and rewrites the
* request's positionals/flags so the rest of `handleOpenCommand`'s existing new-session
* flow (device resolution, surface validation, session creation) runs completely
* unmodified against the resolved device + bundle id. Only iOS simulators are handled;
* see the PR body for scoped-out platforms.
*
* Fails closed with AMBIGUOUS_MATCH — never guesses — when the environment is not
* unambiguous: zero or multiple booted simulators, zero or multiple running apps, or a
* probe failure (the resolver owns the catch-all and reports failure as `undefined`,
* never a rejection). This mirrors `buildIosOpenCommandHint`'s no-guessing contract
* exactly, because it is built from the same underlying probe.
*/
export async function resolveForegroundOpenRequest(params: {
req: DaemonRequest;
hasExistingSession: boolean;
}): Promise<ForegroundOpenResolution> {
const { req, hasExistingSession } = params;
if (req.flags?.foreground !== true) return { type: 'not-requested' };
if (hasExistingSession) {
return {
type: 'response',
response: errorResponse(
'INVALID_ARGS',
'open --foreground only applies to a fresh session. Close the current session first, or omit --foreground.',
),
};
}
if (req.positionals?.[0]) {
return {
type: 'response',
response: errorResponse(
'INVALID_ARGS',
'open --foreground cannot be combined with an explicit app argument.',
),
};
}
const resolved = await resolveSoleForegroundIosApp({
simulatorSetPath: req.flags?.iosSimulatorDeviceSet,
});
if (!resolved) {
return {
type: 'response',
response: errorResponse(
'AMBIGUOUS_MATCH',
'open --foreground requires an unambiguous environment: exactly one booted iOS simulator with exactly one app running.',
{
reason: 'foreground_app_ambiguous',
hint: 'Pass an explicit app instead: agent-device open <app> --platform ios.',
},
),
};
}
return {
type: 'resolved',
req: {
...req,
positionals: [resolved.app.bundleId],
flags: { ...req.flags, udid: resolved.device.id, platform: 'ios' },
},
};
}
/**
* The other half of the `open --foreground` composition: once `handleOpenCommand` has
* created the session (with `session.appBundleId` now set), attach an initial
* interactive snapshot by delegating to the EXISTING `snapshot` runtime dispatch
* (`dispatchSnapshotViaRuntime`) — the same ref-issuing, session-store-updating path
* `agent-device snapshot -i` uses. This is deliberately composition, not a new capture
* pipeline: refs, snapshot lineage, and ref-frame activation all come from the one
* capture path already trusted for `snapshot`.
*
* A no-op when `--foreground` was not requested, or when open itself failed (nothing to
* attach a snapshot to).
*/
export async function composeOpenWithInitialSnapshot(params: {
req: DaemonRequest;
sessionName: string;
logPath: string;
sessionStore: SessionStore;
openResponse: DaemonResponse;
}): Promise<DaemonResponse> {
const { req, sessionName, logPath, sessionStore, openResponse } = params;
if (!openResponse.ok || req.flags?.foreground !== true) return openResponse;
const snapshotResponse = await dispatchSnapshotViaRuntime({
req: { ...req, command: 'snapshot', positionals: [] },
sessionName,
logPath,
sessionStore,
});
if (!snapshotResponse.ok) return snapshotResponse;
return {
ok: true,
data: { ...openResponse.data, snapshot: snapshotResponse.data },
};
}
+9 -1
View File
@@ -54,6 +54,7 @@ import {
validatePreResolvedOpenRequest,
validateResolvedOpenRequest,
} from './session-open-prepare.ts';
import { resolveForegroundOpenRequest } from './session-open-foreground.ts';
import { errorResponse } from './response.ts';
import { expireRefFrame } from '../ref-frame.ts';
import { buildSessionRecoveryHint } from '../session-recovery-hints.ts';
@@ -652,9 +653,16 @@ export async function handleOpenCommand(params: {
logPath: string;
sessionStore: SessionStore;
}): Promise<DaemonResponse> {
const { req, sessionName, logPath, sessionStore } = params;
const { sessionName, logPath, sessionStore } = params;
const session = sessionStore.get(sessionName);
const foregroundResolution = await resolveForegroundOpenRequest({
req: params.req,
hasExistingSession: Boolean(session),
});
if (foregroundResolution.type === 'response') return foregroundResolution.response;
const req = foregroundResolution.type === 'resolved' ? foregroundResolution.req : params.req;
if (session) {
if (req.flags?.saveScript) {
return errorResponse(
+8 -1
View File
@@ -21,6 +21,7 @@ import { errorResponse, requireCommandSupported } from './response.ts';
import { recordSessionAction } from './handler-utils.ts';
import { handleRuntimeCommand } from './session-runtime-command.ts';
import { handleOpenCommand } from './session-open.ts';
import { composeOpenWithInitialSnapshot } from './session-open-foreground.ts';
import {
resolveAndroidPackageForOpen,
resolveSessionAppBundleIdForTarget,
@@ -450,7 +451,13 @@ const SESSION_COMMAND_HANDLER_IMPLS = {
push: handlePushCommand,
'trigger-app-event': handleTriggerAppEventCommand,
open: async ({ req, sessionName, logPath, sessionStore }) =>
await handleOpenCommand({ req, sessionName, logPath, sessionStore }),
await composeOpenWithInitialSnapshot({
req,
sessionName,
logPath,
sessionStore,
openResponse: await handleOpenCommand({ req, sessionName, logPath, sessionStore }),
}),
replay: handleSessionReplayCommandGroup,
test: handleSessionReplayCommandGroup,
batch: async ({ req, sessionName, invoke }) => await runBatchCommands(req, sessionName, invoke),
+51 -1
View File
@@ -7,7 +7,7 @@ vi.mock('../platforms/apple/core/devices.ts', () => ({ listBootedIosSimulators }
vi.mock('../platforms/apple/core/app-resolution.ts', () => ({ detectSoleRunningIosSimulatorApp }));
import { IOS_DEVICE, IOS_SIMULATOR } from '../__tests__/test-utils/index.ts';
import { buildIosOpenCommandHint } from './ios-app-session-hint.ts';
import { buildIosOpenCommandHint, resolveSoleForegroundIosApp } from './ios-app-session-hint.ts';
beforeEach(() => {
listBootedIosSimulators.mockReset();
@@ -122,3 +122,53 @@ test('a physical iOS device never probes for a hint', async () => {
expect(hint).toBeUndefined();
expect(listBootedIosSimulators).not.toHaveBeenCalled();
});
test('resolveSoleForegroundIosApp resolves the device and app when unambiguous', async () => {
const soleBootedDevice = { ...IOS_SIMULATOR, id: 'booted-1', name: 'iPhone 16' };
const app = { bundleId: 'xyz.blueskyweb.app', name: 'Bluesky' };
listBootedIosSimulators.mockResolvedValue([soleBootedDevice]);
detectSoleRunningIosSimulatorApp.mockResolvedValue(app);
const resolved = await resolveSoleForegroundIosApp({ simulatorSetPath: '/custom/set' });
expect(resolved).toEqual({ device: soleBootedDevice, app });
expect(listBootedIosSimulators).toHaveBeenCalledWith({ simulatorSetPath: '/custom/set' });
expect(detectSoleRunningIosSimulatorApp).toHaveBeenCalledWith(soleBootedDevice);
});
test('resolveSoleForegroundIosApp returns undefined for zero booted simulators', async () => {
listBootedIosSimulators.mockResolvedValue([]);
expect(await resolveSoleForegroundIosApp()).toBeUndefined();
expect(detectSoleRunningIosSimulatorApp).not.toHaveBeenCalled();
});
test('resolveSoleForegroundIosApp returns undefined for more than one booted simulator', async () => {
listBootedIosSimulators.mockResolvedValue([
{ ...IOS_SIMULATOR, id: 'booted-1' },
{ ...IOS_SIMULATOR, id: 'booted-2' },
]);
expect(await resolveSoleForegroundIosApp()).toBeUndefined();
expect(detectSoleRunningIosSimulatorApp).not.toHaveBeenCalled();
});
test('resolveSoleForegroundIosApp returns undefined when the running-app probe is inconclusive', async () => {
listBootedIosSimulators.mockResolvedValue([{ ...IOS_SIMULATOR, id: 'booted-1' }]);
detectSoleRunningIosSimulatorApp.mockResolvedValue(undefined);
expect(await resolveSoleForegroundIosApp()).toBeUndefined();
});
test('resolveSoleForegroundIosApp catches a rejecting probe instead of propagating it', async () => {
// The catch-all lives in the resolver, so both consumers (the hint and
// open --foreground) inherit the never-propagate contract from one place.
listBootedIosSimulators.mockRejectedValue(new Error('simctl timed out'));
await expect(resolveSoleForegroundIosApp()).resolves.toBeUndefined();
listBootedIosSimulators.mockResolvedValue([{ ...IOS_SIMULATOR, id: 'booted-1' }]);
detectSoleRunningIosSimulatorApp.mockRejectedValue(new Error('launchctl timed out'));
await expect(resolveSoleForegroundIosApp()).resolves.toBeUndefined();
});
+50 -26
View File
@@ -1,22 +1,55 @@
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
import { detectSoleRunningIosSimulatorApp } from '../platforms/apple/core/app-resolution.ts';
import type { IosAppInfo } from '../platforms/apple/core/app-info.ts';
import { listBootedIosSimulators } from '../platforms/apple/core/devices.ts';
import { shellQuoteIfNeeded } from '../utils/shell-quote.ts';
export type ResolvedForegroundIosApp = {
device: DeviceInfo;
app: IosAppInfo;
};
/**
* Enriches the generic "Run open first" SESSION_NOT_FOUND hint with the exact
* runnable command, but only when the environment is unambiguous: exactly one
* booted iOS simulator with exactly one app running on it.
* The shared ambiguity-detection probe: exactly one booted iOS simulator with
* exactly one app running on it, or `undefined`.
*
* Any ambiguity — no booted simulator, more than one, no running app, more
* than one running app, or a probe failure — returns `undefined` so the
* caller keeps the generic hint. This must never guess: a wrong-but-confident
* command is worse than the default guidance.
* than one running app, or a probe failure — returns `undefined`. This must
* never guess: a wrong-but-confident answer is worse than failing closed.
*
* The whole probe is strictly best-effort: it runs on an error path, so a
* throw here (a bounded probe still rejects on timeout or a spawn failure —
* see app-resolution.ts) must fall back to `undefined`, never replace the
* caller's deterministic error with a probe failure.
* Strictly best-effort: a throw here (a bounded probe still rejects on
* timeout or a spawn failure — see app-resolution.ts) is caught and treated
* as inconclusive, never propagated — callers sit on error/decision paths
* where a probe failure must not replace their deterministic outcome.
*
* `buildIosOpenCommandHint` (error-hint enrichment) and the `open --foreground`
* convenience (RFC, #observe-foreground) both compose this single probe
* instead of re-deriving the ambiguity rules.
*/
export async function resolveSoleForegroundIosApp(
options: { simulatorSetPath?: string } = {},
): Promise<ResolvedForegroundIosApp | undefined> {
try {
const booted = await listBootedIosSimulators({ simulatorSetPath: options.simulatorSetPath });
if (booted.length !== 1) return undefined;
const [soleBootedDevice] = booted;
if (!soleBootedDevice) return undefined;
const app = await detectSoleRunningIosSimulatorApp(soleBootedDevice);
if (!app) return undefined;
return { device: soleBootedDevice, app };
} catch {
return undefined;
}
}
/**
* Enriches the generic "Run open first" SESSION_NOT_FOUND hint with the exact
* runnable command, but only when the environment is unambiguous (see
* `resolveSoleForegroundIosApp`, which also owns the never-guess and
* never-propagate contract). Ambiguity or a probe failure returns `undefined`
* so the caller keeps the generic hint.
*/
// Wire-level details are redacted before send (packages/kernel/src/redaction.ts),
// which silently truncates any string field over 400 chars — a truncated
@@ -29,23 +62,14 @@ const MAX_HINT_LENGTH = 350;
export async function buildIosOpenCommandHint(device: DeviceInfo): Promise<string | undefined> {
if (!isIosFamily(device) || device.kind !== 'simulator') return undefined;
try {
const booted = await listBootedIosSimulators({ simulatorSetPath: device.simulatorSetPath });
if (booted.length !== 1) return undefined;
const [soleBootedDevice] = booted;
if (!soleBootedDevice) return undefined;
const resolved = await resolveSoleForegroundIosApp({ simulatorSetPath: device.simulatorSetPath });
if (!resolved) return undefined;
const app = await detectSoleRunningIosSimulatorApp(soleBootedDevice);
if (!app) return undefined;
const command = buildOpenCommand(soleBootedDevice, app.bundleId);
const hint =
`One booted device found ("${soleBootedDevice.name}", udid ${soleBootedDevice.id}) with ` +
`${app.bundleId} running. Run: ${command}`;
return hint.length <= MAX_HINT_LENGTH ? hint : undefined;
} catch {
return undefined;
}
const command = buildOpenCommand(resolved.device, resolved.app.bundleId);
const hint =
`One booted device found ("${resolved.device.name}", udid ${resolved.device.id}) with ` +
`${resolved.app.bundleId} running. Run: ${command}`;
return hint.length <= MAX_HINT_LENGTH ? hint : undefined;
}
// Always pins --udid: the sole-booted-device check only proves there is one
+1
View File
@@ -164,6 +164,7 @@ export function serializeOpenResult(result: AppOpenResult): Record<string, unkno
...(result.startup ? { startup: result.startup } : {}),
...(result.runtime ? { runtime: result.runtime } : {}),
...(result.device ? serializeSessionDevice(result.device) : {}),
...(result.snapshot ? { snapshot: result.snapshot } : {}),
},
target ? `Opened: ${target}` : 'Opened',
);