agent-device 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +20 -12
  2. package/dist/src/797.js +1 -0
  3. package/dist/src/bin.js +40 -29
  4. package/dist/src/daemon.js +21 -17
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
  6. package/package.json +2 -2
  7. package/skills/agent-device/SKILL.md +23 -14
  8. package/skills/agent-device/references/permissions.md +7 -2
  9. package/skills/agent-device/references/session-management.md +5 -1
  10. package/src/__tests__/cli-close.test.ts +155 -0
  11. package/src/__tests__/cli-help.test.ts +102 -0
  12. package/src/cli.ts +68 -22
  13. package/src/core/__tests__/capabilities.test.ts +2 -1
  14. package/src/core/__tests__/dispatch-open.test.ts +25 -0
  15. package/src/core/__tests__/open-target.test.ts +40 -1
  16. package/src/core/capabilities.ts +1 -1
  17. package/src/core/dispatch.ts +22 -0
  18. package/src/core/open-target.ts +14 -0
  19. package/src/daemon/__tests__/device-ready.test.ts +52 -0
  20. package/src/daemon/__tests__/session-store.test.ts +23 -0
  21. package/src/daemon/device-ready.ts +146 -4
  22. package/src/daemon/handlers/__tests__/session.test.ts +477 -0
  23. package/src/daemon/handlers/session.ts +198 -93
  24. package/src/daemon/handlers/snapshot.ts +210 -185
  25. package/src/daemon/session-store.ts +16 -6
  26. package/src/daemon/types.ts +2 -1
  27. package/src/daemon-client.ts +138 -17
  28. package/src/daemon.ts +99 -9
  29. package/src/platforms/android/__tests__/index.test.ts +118 -1
  30. package/src/platforms/android/index.ts +77 -47
  31. package/src/platforms/ios/__tests__/index.test.ts +292 -4
  32. package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
  33. package/src/platforms/ios/apps.ts +358 -0
  34. package/src/platforms/ios/config.ts +28 -0
  35. package/src/platforms/ios/devicectl.ts +134 -0
  36. package/src/platforms/ios/devices.ts +15 -2
  37. package/src/platforms/ios/index.ts +20 -455
  38. package/src/platforms/ios/runner-client.ts +171 -69
  39. package/src/platforms/ios/simulator.ts +164 -0
  40. package/src/utils/__tests__/args.test.ts +66 -2
  41. package/src/utils/__tests__/daemon-client.test.ts +95 -0
  42. package/src/utils/__tests__/keyed-lock.test.ts +55 -0
  43. package/src/utils/__tests__/process-identity.test.ts +33 -0
  44. package/src/utils/args.ts +37 -1
  45. package/src/utils/command-schema.ts +58 -27
  46. package/src/utils/interactors.ts +2 -2
  47. package/src/utils/keyed-lock.ts +14 -0
  48. package/src/utils/process-identity.ts +100 -0
  49. package/src/utils/timeouts.ts +9 -0
  50. package/dist/src/274.js +0 -1
  51. package/src/daemon/__tests__/app-state.test.ts +0 -138
  52. package/src/daemon/app-state.ts +0 -65
@@ -1,14 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
3
3
  import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
4
- import { isDeepLinkTarget } from '../../core/open-target.ts';
4
+ import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
5
5
  import { AppError, asAppError } from '../../utils/errors.ts';
6
6
  import type { DeviceInfo } from '../../utils/device.ts';
7
7
  import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
8
8
  import { SessionStore } from '../session-store.ts';
9
9
  import { contextFromFlags } from '../context.ts';
10
10
  import { ensureDeviceReady } from '../device-ready.ts';
11
- import { resolveIosAppStateFromSnapshots } from '../app-state.ts';
12
11
  import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
13
12
  import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
14
13
  import { extractNodeText, normalizeType, pruneGroupNodes } from '../snapshot-processing.ts';
@@ -26,17 +25,61 @@ type ReinstallOps = {
26
25
  android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>;
27
26
  };
28
27
 
28
+ const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE =
29
+ 'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "<name>" <app>).';
30
+
31
+ function requireSessionOrExplicitSelector(
32
+ command: string,
33
+ session: SessionState | undefined,
34
+ flags: DaemonRequest['flags'] | undefined,
35
+ ): DaemonResponse | null {
36
+ if (session || hasExplicitDeviceSelector(flags)) {
37
+ return null;
38
+ }
39
+ return {
40
+ ok: false,
41
+ error: {
42
+ code: 'INVALID_ARGS',
43
+ message: `${command} requires an active session or an explicit device selector (e.g. --platform ios).`,
44
+ },
45
+ };
46
+ }
47
+
29
48
  function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean {
30
49
  return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial);
31
50
  }
32
51
 
52
+ function hasExplicitSessionFlag(flags: DaemonRequest['flags'] | undefined): boolean {
53
+ return typeof flags?.session === 'string' && flags.session.trim().length > 0;
54
+ }
55
+
56
+ function selectorTargetsSessionDevice(
57
+ flags: DaemonRequest['flags'] | undefined,
58
+ session: SessionState | undefined,
59
+ ): boolean {
60
+ if (!session) return false;
61
+ if (!hasExplicitDeviceSelector(flags)) return true;
62
+ if (flags?.platform && flags.platform !== session.device.platform) return false;
63
+ if (flags?.udid && flags.udid !== session.device.id) return false;
64
+ if (flags?.serial && flags.serial !== session.device.id) return false;
65
+ if (flags?.device) {
66
+ return flags.device.trim().toLowerCase() === session.device.name.trim().toLowerCase();
67
+ }
68
+ return true;
69
+ }
70
+
33
71
  async function resolveCommandDevice(params: {
34
72
  session: SessionState | undefined;
35
73
  flags: DaemonRequest['flags'] | undefined;
36
74
  ensureReadyFn: typeof ensureDeviceReady;
75
+ resolveTargetDeviceFn: typeof resolveTargetDevice;
37
76
  ensureReady?: boolean;
38
77
  }): Promise<DeviceInfo> {
39
- const device = params.session?.device ?? (await resolveTargetDevice(params.flags ?? {}));
78
+ const shouldUseExplicitSelector = hasExplicitDeviceSelector(params.flags);
79
+ const device =
80
+ shouldUseExplicitSelector || !params.session
81
+ ? await params.resolveTargetDeviceFn(params.flags ?? {})
82
+ : params.session.device;
40
83
  if (params.ensureReady !== false) {
41
84
  await params.ensureReadyFn(device);
42
85
  }
@@ -54,10 +97,22 @@ const defaultReinstallOps: ReinstallOps = {
54
97
  },
55
98
  };
56
99
 
57
- async function resolveIosBundleIdForOpen(device: DeviceInfo, openTarget: string | undefined): Promise<string | undefined> {
58
- if (device.platform !== 'ios' || !openTarget || isDeepLinkTarget(openTarget)) {
100
+ async function resolveIosBundleIdForOpen(
101
+ device: DeviceInfo,
102
+ openTarget: string | undefined,
103
+ currentAppBundleId?: string,
104
+ ): Promise<string | undefined> {
105
+ if (device.platform !== 'ios' || !openTarget) return undefined;
106
+ if (isDeepLinkTarget(openTarget)) {
107
+ if (device.kind === 'device') {
108
+ return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget);
109
+ }
59
110
  return undefined;
60
111
  }
112
+ return await tryResolveIosAppBundleId(device, openTarget);
113
+ }
114
+
115
+ async function tryResolveIosAppBundleId(device: DeviceInfo, openTarget: string): Promise<string | undefined> {
61
116
  try {
62
117
  const { resolveIosApp } = await import('../../platforms/ios/index.ts');
63
118
  return await resolveIosApp(device, openTarget);
@@ -66,6 +121,92 @@ async function resolveIosBundleIdForOpen(device: DeviceInfo, openTarget: string
66
121
  }
67
122
  }
68
123
 
124
+ async function handleAppStateCommand(params: {
125
+ req: DaemonRequest;
126
+ sessionName: string;
127
+ sessionStore: SessionStore;
128
+ ensureReady: typeof ensureDeviceReady;
129
+ resolveDevice: typeof resolveTargetDevice;
130
+ }): Promise<DaemonResponse> {
131
+ const { req, sessionName, sessionStore, ensureReady, resolveDevice } = params;
132
+ const session = sessionStore.get(sessionName);
133
+ const flags = req.flags ?? {};
134
+ if (!session && hasExplicitSessionFlag(flags)) {
135
+ const iOSSessionHint =
136
+ flags.platform === 'ios'
137
+ ? `No active session "${sessionName}". Run open with --session ${sessionName} first.`
138
+ : `No active session "${sessionName}". Run open with --session ${sessionName} first, or omit --session to query by device selector.`;
139
+ return {
140
+ ok: false,
141
+ error: {
142
+ code: 'SESSION_NOT_FOUND',
143
+ message: iOSSessionHint,
144
+ },
145
+ };
146
+ }
147
+ const guard = requireSessionOrExplicitSelector('appstate', session, flags);
148
+ if (guard) return guard;
149
+
150
+ const shouldUseSessionStateForIos = session?.device.platform === 'ios' && selectorTargetsSessionDevice(flags, session);
151
+ const targetsIos = flags.platform === 'ios';
152
+ if (targetsIos && !shouldUseSessionStateForIos) {
153
+ return {
154
+ ok: false,
155
+ error: {
156
+ code: 'SESSION_NOT_FOUND',
157
+ message: IOS_APPSTATE_SESSION_REQUIRED_MESSAGE,
158
+ },
159
+ };
160
+ }
161
+ if (shouldUseSessionStateForIos) {
162
+ const appName = session.appName ?? session.appBundleId;
163
+ if (!session.appName && !session.appBundleId) {
164
+ return {
165
+ ok: false,
166
+ error: {
167
+ code: 'COMMAND_FAILED',
168
+ message: 'No foreground app is tracked for this iOS session. Open an app in the session, then retry appstate.',
169
+ },
170
+ };
171
+ }
172
+ return {
173
+ ok: true,
174
+ data: {
175
+ platform: 'ios',
176
+ appName: appName ?? 'unknown',
177
+ appBundleId: session.appBundleId,
178
+ source: 'session',
179
+ },
180
+ };
181
+ }
182
+ const device = await resolveCommandDevice({
183
+ session,
184
+ flags,
185
+ ensureReadyFn: ensureReady,
186
+ resolveTargetDeviceFn: resolveDevice,
187
+ ensureReady: true,
188
+ });
189
+ if (device.platform === 'ios') {
190
+ return {
191
+ ok: false,
192
+ error: {
193
+ code: 'SESSION_NOT_FOUND',
194
+ message: IOS_APPSTATE_SESSION_REQUIRED_MESSAGE,
195
+ },
196
+ };
197
+ }
198
+ const { getAndroidAppState } = await import('../../platforms/android/index.ts');
199
+ const state = await getAndroidAppState(device);
200
+ return {
201
+ ok: true,
202
+ data: {
203
+ platform: 'android',
204
+ package: state.package,
205
+ activity: state.activity,
206
+ },
207
+ };
208
+ }
209
+
69
210
  export async function handleSessionCommands(params: {
70
211
  req: DaemonRequest;
71
212
  sessionName: string;
@@ -74,6 +215,7 @@ export async function handleSessionCommands(params: {
74
215
  invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
75
216
  dispatch?: typeof dispatchCommand;
76
217
  ensureReady?: typeof ensureDeviceReady;
218
+ resolveTargetDevice?: typeof resolveTargetDevice;
77
219
  reinstallOps?: ReinstallOps;
78
220
  }): Promise<DaemonResponse | null> {
79
221
  const {
@@ -84,10 +226,12 @@ export async function handleSessionCommands(params: {
84
226
  invoke,
85
227
  dispatch: dispatchOverride,
86
228
  ensureReady: ensureReadyOverride,
229
+ resolveTargetDevice: resolveTargetDeviceOverride,
87
230
  reinstallOps = defaultReinstallOps,
88
231
  } = params;
89
232
  const dispatch = dispatchOverride ?? dispatchCommand;
90
233
  const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
234
+ const resolveDevice = resolveTargetDeviceOverride ?? resolveTargetDevice;
91
235
  const command = req.command;
92
236
 
93
237
  if (command === 'session_list') {
@@ -136,56 +280,50 @@ export async function handleSessionCommands(params: {
136
280
  if (command === 'apps') {
137
281
  const session = sessionStore.get(sessionName);
138
282
  const flags = req.flags ?? {};
139
- if (!session && !hasExplicitDeviceSelector(flags)) {
140
- return {
141
- ok: false,
142
- error: {
143
- code: 'INVALID_ARGS',
144
- message: 'apps requires an active session or an explicit device selector (e.g. --platform ios).',
145
- },
146
- };
147
- }
148
- const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
283
+ const guard = requireSessionOrExplicitSelector(command, session, flags);
284
+ if (guard) return guard;
285
+ const device = await resolveCommandDevice({
286
+ session,
287
+ flags,
288
+ ensureReadyFn: ensureReady,
289
+ resolveTargetDeviceFn: resolveDevice,
290
+ ensureReady: true,
291
+ });
149
292
  if (!isCommandSupportedOnDevice('apps', device)) {
150
293
  return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
151
294
  }
295
+ const appsFilter = req.flags?.appsFilter ?? 'all';
152
296
  if (device.platform === 'ios') {
153
- const { listSimulatorApps } = await import('../../platforms/ios/index.ts');
154
- const apps = await listSimulatorApps(device);
155
- if (req.flags?.appsMetadata) {
156
- return { ok: true, data: { apps } };
157
- }
297
+ const { listIosApps } = await import('../../platforms/ios/index.ts');
298
+ const apps = await listIosApps(device, appsFilter);
158
299
  const formatted = apps.map((app) =>
159
300
  app.name && app.name !== app.bundleId ? `${app.name} (${app.bundleId})` : app.bundleId,
160
301
  );
161
302
  return { ok: true, data: { apps: formatted } };
162
303
  }
163
- const { listAndroidApps, listAndroidAppsMetadata } = await import('../../platforms/android/index.ts');
164
- if (req.flags?.appsMetadata) {
165
- const apps = await listAndroidAppsMetadata(device, req.flags?.appsFilter);
166
- return { ok: true, data: { apps } };
167
- }
168
- const apps = await listAndroidApps(device, req.flags?.appsFilter);
169
- return { ok: true, data: { apps } };
304
+ const { listAndroidApps } = await import('../../platforms/android/index.ts');
305
+ const apps = await listAndroidApps(device, appsFilter);
306
+ const formatted = apps.map((app) =>
307
+ app.name && app.name !== app.package ? `${app.name} (${app.package})` : app.package,
308
+ );
309
+ return { ok: true, data: { apps: formatted } };
170
310
  }
171
311
 
172
312
  if (command === 'boot') {
173
313
  const session = sessionStore.get(sessionName);
174
314
  const flags = req.flags ?? {};
175
- if (!session && !hasExplicitDeviceSelector(flags)) {
176
- return {
177
- ok: false,
178
- error: {
179
- code: 'INVALID_ARGS',
180
- message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
181
- },
182
- };
183
- }
184
- const device = session?.device ?? (await resolveTargetDevice(flags));
315
+ const guard = requireSessionOrExplicitSelector(command, session, flags);
316
+ if (guard) return guard;
317
+ const device = await resolveCommandDevice({
318
+ session,
319
+ flags,
320
+ ensureReadyFn: ensureReady,
321
+ resolveTargetDeviceFn: resolveDevice,
322
+ ensureReady: true,
323
+ });
185
324
  if (!isCommandSupportedOnDevice('boot', device)) {
186
325
  return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
187
326
  }
188
- await ensureReady(device);
189
327
  return {
190
328
  ok: true,
191
329
  data: {
@@ -199,61 +337,20 @@ export async function handleSessionCommands(params: {
199
337
  }
200
338
 
201
339
  if (command === 'appstate') {
202
- const session = sessionStore.get(sessionName);
203
- const flags = req.flags ?? {};
204
- const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
205
- if (device.platform === 'ios') {
206
- if (session?.appBundleId) {
207
- return {
208
- ok: true,
209
- data: {
210
- platform: 'ios',
211
- appBundleId: session.appBundleId,
212
- appName: session.appName ?? session.appBundleId,
213
- source: 'session',
214
- },
215
- };
216
- }
217
- const snapshotResult = await resolveIosAppStateFromSnapshots(
218
- device,
219
- logPath,
220
- session?.trace?.outPath,
221
- req.flags,
222
- );
223
- return {
224
- ok: true,
225
- data: {
226
- platform: 'ios',
227
- appName: snapshotResult.appName,
228
- appBundleId: snapshotResult.appBundleId,
229
- source: snapshotResult.source,
230
- },
231
- };
232
- }
233
- const { getAndroidAppState } = await import('../../platforms/android/index.ts');
234
- const state = await getAndroidAppState(device);
235
- return {
236
- ok: true,
237
- data: {
238
- platform: 'android',
239
- package: state.package,
240
- activity: state.activity,
241
- },
242
- };
340
+ return await handleAppStateCommand({
341
+ req,
342
+ sessionName,
343
+ sessionStore,
344
+ ensureReady,
345
+ resolveDevice,
346
+ });
243
347
  }
244
348
 
245
349
  if (command === 'reinstall') {
246
350
  const session = sessionStore.get(sessionName);
247
351
  const flags = req.flags ?? {};
248
- if (!session && !hasExplicitDeviceSelector(flags)) {
249
- return {
250
- ok: false,
251
- error: {
252
- code: 'INVALID_ARGS',
253
- message: 'reinstall requires an active session or an explicit device selector (e.g. --platform ios).',
254
- },
255
- };
256
- }
352
+ const guard = requireSessionOrExplicitSelector(command, session, flags);
353
+ if (guard) return guard;
257
354
  const app = req.positionals?.[0]?.trim();
258
355
  const appPathInput = req.positionals?.[1]?.trim();
259
356
  if (!app || !appPathInput) {
@@ -269,7 +366,13 @@ export async function handleSessionCommands(params: {
269
366
  error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` },
270
367
  };
271
368
  }
272
- const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: false });
369
+ const device = await resolveCommandDevice({
370
+ session,
371
+ flags,
372
+ ensureReadyFn: ensureReady,
373
+ resolveTargetDeviceFn: resolveDevice,
374
+ ensureReady: false,
375
+ });
273
376
  if (!isCommandSupportedOnDevice('reinstall', device)) {
274
377
  return {
275
378
  ok: false,
@@ -331,7 +434,8 @@ export async function handleSessionCommands(params: {
331
434
  },
332
435
  };
333
436
  }
334
- const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget);
437
+ await ensureReady(session.device);
438
+ const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget, session.appBundleId);
335
439
  const openPositionals = requestedOpenTarget ? (req.positionals ?? []) : [openTarget];
336
440
  if (shouldRelaunch) {
337
441
  const closeTarget = appBundleId ?? openTarget;
@@ -346,7 +450,7 @@ export async function handleSessionCommands(params: {
346
450
  ...session,
347
451
  appBundleId,
348
452
  appName: openTarget,
349
- recordSession: session.recordSession || req.flags?.saveScript === true,
453
+ recordSession: session.recordSession || Boolean(req.flags?.saveScript),
350
454
  snapshot: undefined,
351
455
  };
352
456
  sessionStore.recordAction(nextSession, {
@@ -377,7 +481,7 @@ export async function handleSessionCommands(params: {
377
481
  },
378
482
  };
379
483
  }
380
- const device = await resolveTargetDevice(req.flags ?? {});
484
+ const device = await resolveDevice(req.flags ?? {});
381
485
  const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
382
486
  if (inUse) {
383
487
  return {
@@ -389,6 +493,7 @@ export async function handleSessionCommands(params: {
389
493
  },
390
494
  };
391
495
  }
496
+ await ensureReady(device);
392
497
  const appBundleId = await resolveIosBundleIdForOpen(device, openTarget);
393
498
  if (shouldRelaunch && openTarget) {
394
499
  const closeTarget = appBundleId ?? openTarget;
@@ -405,7 +510,7 @@ export async function handleSessionCommands(params: {
405
510
  createdAt: Date.now(),
406
511
  appBundleId,
407
512
  appName: openTarget,
408
- recordSession: req.flags?.saveScript === true,
513
+ recordSession: Boolean(req.flags?.saveScript),
409
514
  actions: [],
410
515
  };
411
516
  sessionStore.recordAction(session, {