Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 67 additions & 0 deletions packages/client/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ import type {
SessionInfo,
SessionNotification,
SessionRecord,
SimulatorDevice,
SimulatorImageFormat,
SimulatorStatus,
StartOptions,
TerminalMetadata,
TerminalReplayEvent,
Expand Down Expand Up @@ -103,6 +106,7 @@ export interface AssetSettledEvent {
type AssetProgressCb = (event: AssetProgressEvent) => void;
type AssetSettledCb = (event: AssetSettledEvent) => void;
type AgentRuntimesChangedCb = (runtimes: AgentRuntimes) => void;
type SimulatorDevicesChangedCb = (devices: SimulatorDevice[]) => void;
type ConnectionCloseCb = (error: Error) => void;

/** A broadcast about a schedule's or its runs' state — the three `schedule.*` push variants. */
Expand Down Expand Up @@ -142,6 +146,7 @@ export class LinkCodeClient {
private readonly assetProgressSubs = new Set<AssetProgressCb>();
private readonly assetSettledSubs = new Set<AssetSettledCb>();
private readonly agentRuntimesChangedSubs = new Set<AgentRuntimesChangedCb>();
private readonly simulatorDevicesChangedSubs = new Set<SimulatorDevicesChangedCb>();
private readonly connectionCloseSubs = new Set<ConnectionCloseCb>();
private unsub: Unsubscribe | null = null;
private offClose: Unsubscribe | null = null;
Expand Down Expand Up @@ -307,6 +312,21 @@ export class LinkCodeClient {
case 'agent-runtime.changed':
for (const cb of this.agentRuntimesChangedSubs) cb(p.runtimes);
break;
case 'simulator.status.result':
this.pending.resolve('simulatorStatus', p.replyTo, p.status);
break;
case 'simulator.listed':
this.pending.resolve('simulatorList', p.replyTo, p.devices);
break;
case 'simulator.launched':
this.pending.resolve('simulatorLaunch', p.replyTo, p.pid);
break;
case 'simulator.screenshotted':
this.pending.resolve('simulatorScreenshot', p.replyTo, { format: p.format, data: p.data });
break;
case 'simulator.devices.changed':
for (const cb of this.simulatorDevicesChangedSubs) cb(p.devices);
break;
case 'asset.listed':
this.pending.resolve('assetList', p.replyTo, p.assets);
break;
Expand Down Expand Up @@ -602,6 +622,53 @@ export class LinkCodeClient {
return () => this.agentRuntimesChangedSubs.delete(cb);
}

/** Whether this host can drive iOS Simulators; gate the whole simulator surface on it. */
simulatorStatus(): Promise<SimulatorStatus> {
return this.control.simulatorStatus();
}

simulatorList(): Promise<SimulatorDevice[]> {
return this.control.simulatorList();
}

simulatorBoot(sessionId: SessionId, udid: string): Promise<RequestAck> {
return this.control.simulatorBoot(sessionId, udid);
}

simulatorShutdown(sessionId: SessionId, udid: string): Promise<RequestAck> {
return this.control.simulatorShutdown(sessionId, udid);
}

simulatorInstall(sessionId: SessionId, udid: string, appPath: string): Promise<RequestAck> {
return this.control.simulatorInstall(sessionId, udid, appPath);
}

simulatorLaunch(sessionId: SessionId, udid: string, bundleId: string): Promise<number | null> {
return this.control.simulatorLaunch(sessionId, udid, bundleId);
}

simulatorTerminate(sessionId: SessionId, udid: string, bundleId: string): Promise<RequestAck> {
return this.control.simulatorTerminate(sessionId, udid, bundleId);
}

simulatorOpenUrl(sessionId: SessionId, udid: string, url: string): Promise<RequestAck> {
return this.control.simulatorOpenUrl(sessionId, udid, url);
}

simulatorScreenshot(
sessionId: SessionId,
udid: string,
format?: SimulatorImageFormat,
): Promise<{ format: SimulatorImageFormat; data: string }> {
return this.control.simulatorScreenshot(sessionId, udid, format);
}

/** Pushed after a state-changing simulator command (boot/shutdown) with a fresh device list. */
subscribeSimulatorDevicesChanged(cb: SimulatorDevicesChangedCb): Unsubscribe {
this.simulatorDevicesChangedSubs.add(cb);
return () => this.simulatorDevicesChangedSubs.delete(cb);
}

setProviderConfig(providers: ProvidersConfig): Promise<RequestAck> {
return this.control.setProviderConfig(providers);
}
Expand Down
95 changes: 95 additions & 0 deletions packages/client/core/src/client/control-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import type {
SessionId,
SessionInfo,
SessionRecord,
SimulatorDevice,
SimulatorImageFormat,
SimulatorStatus,
StartOptions,
WirePayload,
WorkspaceFile,
Expand Down Expand Up @@ -563,6 +566,98 @@ export class ControlChannel {
}));
}

// iOS Simulator (CODE-394). Commands are session-scoped: the engine claims the device for
// `sessionId` (ownership/cap rules) before touching it. Gate the whole surface on
// `simulatorStatus().available`.

simulatorStatus(): Promise<SimulatorStatus> {
return this.sendCorrelated('simulatorStatus', (clientReqId) => ({
kind: 'simulator.status',
clientReqId,
}));
}

simulatorList(): Promise<SimulatorDevice[]> {
return this.sendCorrelated('simulatorList', (clientReqId) => ({
kind: 'simulator.list',
clientReqId,
}));
}

simulatorBoot(sessionId: SessionId, udid: string): Promise<RequestAck> {
return this.sendCorrelated('ack', (clientReqId) => ({
kind: 'simulator.boot',
clientReqId,
sessionId,
udid,
}));
}

simulatorShutdown(sessionId: SessionId, udid: string): Promise<RequestAck> {
return this.sendCorrelated('ack', (clientReqId) => ({
kind: 'simulator.shutdown',
clientReqId,
sessionId,
udid,
}));
}

simulatorInstall(sessionId: SessionId, udid: string, appPath: string): Promise<RequestAck> {
return this.sendCorrelated('ack', (clientReqId) => ({
kind: 'simulator.install',
clientReqId,
sessionId,
udid,
appPath,
}));
}

/** Resolves with the launched pid (`null` when the host could not report one). */
simulatorLaunch(sessionId: SessionId, udid: string, bundleId: string): Promise<number | null> {
return this.sendCorrelated('simulatorLaunch', (clientReqId) => ({
kind: 'simulator.launch',
clientReqId,
sessionId,
udid,
bundleId,
}));
}

simulatorTerminate(sessionId: SessionId, udid: string, bundleId: string): Promise<RequestAck> {
return this.sendCorrelated('ack', (clientReqId) => ({
kind: 'simulator.terminate',
clientReqId,
sessionId,
udid,
bundleId,
}));
}

simulatorOpenUrl(sessionId: SessionId, udid: string, url: string): Promise<RequestAck> {
return this.sendCorrelated('ack', (clientReqId) => ({
kind: 'simulator.open-url',
clientReqId,
sessionId,
udid,
url,
}));
}

/** Resolves with base64-encoded image bytes in the requested format (default jpeg). */
simulatorScreenshot(
sessionId: SessionId,
udid: string,
format?: SimulatorImageFormat,
): Promise<{ format: SimulatorImageFormat; data: string }> {
return this.sendCorrelated('simulatorScreenshot', (clientReqId) => ({
kind: 'simulator.screenshot',
clientReqId,
sessionId,
udid,
format,
}));
}

private sendCorrelated<K extends keyof PendingValueMap>(
kind: K,
makePayload: (clientReqId: string) => WirePayload,
Expand Down
11 changes: 11 additions & 0 deletions packages/client/core/src/client/pending-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import type {
SessionId,
SessionInfo,
SessionRecord,
SimulatorDevice,
SimulatorImageFormat,
SimulatorStatus,
TerminalMetadata,
WirePayload,
WorkspaceFile,
Expand Down Expand Up @@ -91,6 +94,10 @@ export interface PendingValueMap {
terminalList: TerminalMetadata[];
terminalAttach: { terminal: TerminalMetadata; truncated: boolean };
agentLoginStart: string;
simulatorStatus: SimulatorStatus;
simulatorList: SimulatorDevice[];
simulatorLaunch: number | null;
simulatorScreenshot: { format: SimulatorImageFormat; data: string };
}

type PendingMaps = { [K in keyof PendingValueMap]: Map<string, Pending<PendingValueMap[K]>> };
Expand Down Expand Up @@ -135,6 +142,10 @@ export class PendingRegistry {
terminalList: new Map(),
terminalAttach: new Map(),
agentLoginStart: new Map(),
simulatorStatus: new Map(),
simulatorList: new Map(),
simulatorLaunch: new Map(),
simulatorScreenshot: new Map(),
};

private readonly randomUUID: RandomUUID;
Expand Down
1 change: 1 addition & 0 deletions packages/foundation/schema/src/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './question';
export * from './schedule';
export * from './script';
export * from './session';
export * from './simulator';
export * from './terminal';
export * from './tool-call';
export * from './usage';
Expand Down
34 changes: 34 additions & 0 deletions packages/foundation/schema/src/model/simulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { z } from 'zod';

/**
* iOS Simulator model shapes shared by the wire and clients. The host-side source of truth is
* the `linkcode-sim` sidecar (`crates/linkcode-sim/PROTOCOL.md`), reached through the engine's
* simulator service — device state is CoreSimulator's, not ours.
*/

export const SimulatorDeviceSchema = z.object({
udid: z.string().min(1),
name: z.string(),
/** CoreSimulator state string: `Shutdown`, `Booted`, `Booting`, … */
state: z.string(),
/** Runtime identifier, e.g. `com.apple.CoreSimulator.SimRuntime.iOS-26-5`. */
runtime: z.string(),
/** Human-readable runtime name (`iOS 26.5`); absent when unknown. */
runtimeName: z.string().optional(),
deviceType: z.string().nullable(),
});
export type SimulatorDevice = z.infer<typeof SimulatorDeviceSchema>;

/** Whether this host can drive simulators at all (macOS + Xcode with the iOS platform). */
export const SimulatorStatusSchema = z.object({
available: z.boolean(),
/** Where simctl lives; present when available. */
simctlPath: z.string().optional(),
developerDir: z.string().optional(),
/** Why unavailable (e.g. Xcode missing); present when not available. */
reason: z.string().optional(),
});
export type SimulatorStatus = z.infer<typeof SimulatorStatusSchema>;

export const SimulatorImageFormatSchema = z.enum(['jpeg', 'png']);
export type SimulatorImageFormat = z.infer<typeof SimulatorImageFormatSchema>;
1 change: 1 addition & 0 deletions packages/foundation/schema/src/wire/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { WirePayloadSchema } from './payload';
// schema it does not speak.
// 43 combines 42's agent.catalog/agent.cataloged with CODE-316's parallel 42 bump for
// file.host/file.hosted, keeping every distinct schema on a distinct protocol version.
// 44 adds the simulator.* variants (CODE-394).
export const WIRE_PROTOCOL_VERSION = 44 as const;

/** Complete wire message: version + unique id + timestamp + payload. */
Expand Down
2 changes: 2 additions & 0 deletions packages/foundation/schema/src/wire/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { requestWireVariants } from './request';
import { scheduleWireVariants } from './schedule';
import { scriptWireVariants } from './script';
import { sessionWireVariants } from './session';
import { simulatorWireVariants } from './simulator';
import { terminalWireVariants } from './terminal';
import { workspaceWireVariants } from './workspace';

Expand All @@ -37,6 +38,7 @@ export const WirePayloadSchema = z.discriminatedUnion('kind', [
...artifactWireVariants,
...agentWireVariants,
...terminalWireVariants,
...simulatorWireVariants,
...keepAliveWireVariants,
]);
export type WirePayload = z.infer<typeof WirePayloadSchema>;
Loading
Loading