agent-device 0.4.1 → 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 (41) hide show
  1. package/README.md +18 -12
  2. package/dist/src/bin.js +32 -32
  3. package/dist/src/daemon.js +18 -14
  4. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
  5. package/package.json +1 -1
  6. package/skills/agent-device/SKILL.md +19 -13
  7. package/skills/agent-device/references/permissions.md +7 -2
  8. package/skills/agent-device/references/session-management.md +3 -1
  9. package/src/__tests__/cli-close.test.ts +155 -0
  10. package/src/cli.ts +32 -16
  11. package/src/core/__tests__/capabilities.test.ts +2 -1
  12. package/src/core/__tests__/dispatch-open.test.ts +25 -0
  13. package/src/core/__tests__/open-target.test.ts +40 -1
  14. package/src/core/capabilities.ts +1 -1
  15. package/src/core/dispatch.ts +22 -0
  16. package/src/core/open-target.ts +14 -0
  17. package/src/daemon/__tests__/device-ready.test.ts +52 -0
  18. package/src/daemon/device-ready.ts +146 -4
  19. package/src/daemon/handlers/__tests__/session.test.ts +477 -0
  20. package/src/daemon/handlers/session.ts +196 -91
  21. package/src/daemon/session-store.ts +0 -2
  22. package/src/daemon-client.ts +118 -18
  23. package/src/platforms/android/__tests__/index.test.ts +118 -1
  24. package/src/platforms/android/index.ts +77 -47
  25. package/src/platforms/ios/__tests__/index.test.ts +292 -4
  26. package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
  27. package/src/platforms/ios/apps.ts +358 -0
  28. package/src/platforms/ios/config.ts +28 -0
  29. package/src/platforms/ios/devicectl.ts +134 -0
  30. package/src/platforms/ios/devices.ts +15 -2
  31. package/src/platforms/ios/index.ts +20 -455
  32. package/src/platforms/ios/runner-client.ts +72 -16
  33. package/src/platforms/ios/simulator.ts +164 -0
  34. package/src/utils/__tests__/args.test.ts +20 -2
  35. package/src/utils/__tests__/daemon-client.test.ts +21 -4
  36. package/src/utils/args.ts +6 -1
  37. package/src/utils/command-schema.ts +7 -14
  38. package/src/utils/interactors.ts +2 -2
  39. package/src/utils/timeouts.ts +9 -0
  40. package/src/daemon/__tests__/app-state.test.ts +0 -138
  41. 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;
@@ -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;
@@ -177,7 +177,6 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags']
177
177
  snapshotScope,
178
178
  snapshotRaw,
179
179
  snapshotBackend,
180
- appsMetadata,
181
180
  relaunch,
182
181
  saveScript,
183
182
  noRecord,
@@ -195,7 +194,6 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags']
195
194
  snapshotScope,
196
195
  snapshotRaw,
197
196
  snapshotBackend,
198
- appsMetadata,
199
197
  relaunch,
200
198
  saveScript,
201
199
  noRecord,
@@ -6,7 +6,10 @@ import { AppError } from './utils/errors.ts';
6
6
  import type { CommandFlags } from './core/dispatch.ts';
7
7
  import { runCmdDetached } from './utils/exec.ts';
8
8
  import { findProjectRoot, readVersion } from './utils/version.ts';
9
- import { stopProcessForTakeover } from './utils/process-identity.ts';
9
+ import {
10
+ isAgentDeviceDaemonProcess,
11
+ stopProcessForTakeover,
12
+ } from './utils/process-identity.ts';
10
13
 
11
14
  export type DaemonRequest = {
12
15
  token: string;
@@ -28,8 +31,20 @@ type DaemonInfo = {
28
31
  processStartTime?: string;
29
32
  };
30
33
 
34
+ type DaemonLockInfo = {
35
+ pid: number;
36
+ processStartTime?: string;
37
+ startedAt?: number;
38
+ };
39
+
40
+ type DaemonMetadataState = {
41
+ hasInfo: boolean;
42
+ hasLock: boolean;
43
+ };
44
+
31
45
  const baseDir = path.join(os.homedir(), '.agent-device');
32
46
  const infoPath = path.join(baseDir, 'daemon.json');
47
+ const lockPath = path.join(baseDir, 'daemon.lock');
33
48
  const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
34
49
  const DAEMON_STARTUP_TIMEOUT_MS = 5000;
35
50
  const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
@@ -51,19 +66,54 @@ async function ensureDaemon(): Promise<DaemonInfo> {
51
66
  removeDaemonInfo();
52
67
  }
53
68
 
69
+ cleanupStaleDaemonLockIfSafe();
54
70
  await startDaemon();
71
+ const started = await waitForDaemonInfo(DAEMON_STARTUP_TIMEOUT_MS);
72
+ if (started) return started;
55
73
 
74
+ if (await recoverDaemonLockHolder()) {
75
+ await startDaemon();
76
+ const recovered = await waitForDaemonInfo(DAEMON_STARTUP_TIMEOUT_MS);
77
+ if (recovered) return recovered;
78
+ }
79
+
80
+ throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
81
+ kind: 'daemon_startup_failed',
82
+ infoPath,
83
+ lockPath,
84
+ hint: resolveDaemonStartupHint(getDaemonMetadataState()),
85
+ });
86
+ }
87
+
88
+ async function waitForDaemonInfo(timeoutMs: number): Promise<DaemonInfo | null> {
56
89
  const start = Date.now();
57
- while (Date.now() - start < DAEMON_STARTUP_TIMEOUT_MS) {
90
+ while (Date.now() - start < timeoutMs) {
58
91
  const info = readDaemonInfo();
59
92
  if (info && (await canConnect(info))) return info;
60
93
  await new Promise((resolve) => setTimeout(resolve, 100));
61
94
  }
95
+ return null;
96
+ }
62
97
 
63
- throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
64
- infoPath,
65
- hint: 'Run pnpm build, or delete ~/.agent-device/daemon.json if stale.',
98
+ async function recoverDaemonLockHolder(): Promise<boolean> {
99
+ const state = getDaemonMetadataState();
100
+ if (!state.hasLock || state.hasInfo) return false;
101
+ const lockInfo = readDaemonLockInfo();
102
+ if (!lockInfo) {
103
+ removeDaemonLock();
104
+ return true;
105
+ }
106
+ if (!isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) {
107
+ removeDaemonLock();
108
+ return true;
109
+ }
110
+ await stopProcessForTakeover(lockInfo.pid, {
111
+ termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
112
+ killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
113
+ expectedStartTime: lockInfo.processStartTime,
66
114
  });
115
+ removeDaemonLock();
116
+ return true;
67
117
  }
68
118
 
69
119
  async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
@@ -75,24 +125,65 @@ async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
75
125
  }
76
126
 
77
127
  function readDaemonInfo(): DaemonInfo | null {
78
- if (!fs.existsSync(infoPath)) return null;
128
+ const data = readJsonFile(infoPath) as DaemonInfo | null;
129
+ if (!data || !data.port || !data.token) return null;
130
+ return {
131
+ ...data,
132
+ pid: Number.isInteger(data.pid) && data.pid > 0 ? data.pid : 0,
133
+ };
134
+ }
135
+
136
+ function readDaemonLockInfo(): DaemonLockInfo | null {
137
+ const data = readJsonFile(lockPath) as DaemonLockInfo | null;
138
+ if (!data || !Number.isInteger(data.pid) || data.pid <= 0) {
139
+ return null;
140
+ }
141
+ return data;
142
+ }
143
+
144
+ function removeDaemonInfo(): void {
145
+ removeFileIfExists(infoPath);
146
+ }
147
+
148
+ function removeDaemonLock(): void {
149
+ removeFileIfExists(lockPath);
150
+ }
151
+
152
+ function cleanupStaleDaemonLockIfSafe(): void {
153
+ const state = getDaemonMetadataState();
154
+ if (!state.hasLock || state.hasInfo) return;
155
+ const lockInfo = readDaemonLockInfo();
156
+ if (!lockInfo) {
157
+ removeDaemonLock();
158
+ return;
159
+ }
160
+ if (isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) {
161
+ return;
162
+ }
163
+ removeDaemonLock();
164
+ }
165
+
166
+ function getDaemonMetadataState(): DaemonMetadataState {
167
+ return {
168
+ hasInfo: fs.existsSync(infoPath),
169
+ hasLock: fs.existsSync(lockPath),
170
+ };
171
+ }
172
+
173
+ function readJsonFile(filePath: string): unknown | null {
174
+ if (!fs.existsSync(filePath)) return null;
79
175
  try {
80
- const data = JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo;
81
- if (!data.port || !data.token) return null;
82
- return {
83
- ...data,
84
- pid: Number.isInteger(data.pid) && data.pid > 0 ? data.pid : 0,
85
- };
176
+ return JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
86
177
  } catch {
87
178
  return null;
88
179
  }
89
180
  }
90
181
 
91
- function removeDaemonInfo(): void {
182
+ function removeFileIfExists(filePath: string): void {
92
183
  try {
93
- if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
184
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
94
185
  } catch {
95
- // Best-effort cleanup only; daemon can still overwrite this file on startup.
186
+ // Best-effort cleanup only.
96
187
  }
97
188
  }
98
189
 
@@ -164,9 +255,18 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
164
255
  }
165
256
 
166
257
  export function resolveDaemonRequestTimeoutMs(raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS): number {
167
- // iOS physical-device runner startup/build can exceed 60s, so use a safer default for daemon RPCs.
168
- if (!raw) return 180000;
258
+ if (!raw) return 90000;
169
259
  const parsed = Number(raw);
170
- if (!Number.isFinite(parsed)) return 180000;
260
+ if (!Number.isFinite(parsed)) return 90000;
171
261
  return Math.max(1000, Math.floor(parsed));
172
262
  }
263
+
264
+ export function resolveDaemonStartupHint(state: { hasInfo: boolean; hasLock: boolean }): string {
265
+ if (state.hasLock && !state.hasInfo) {
266
+ return 'Detected ~/.agent-device/daemon.lock without daemon.json. If no agent-device daemon process is running, delete ~/.agent-device/daemon.lock and retry.';
267
+ }
268
+ if (state.hasLock && state.hasInfo) {
269
+ return 'Daemon metadata may be stale. If no agent-device daemon process is running, delete ~/.agent-device/daemon.json and ~/.agent-device/daemon.lock, then retry.';
270
+ }
271
+ return 'Daemon metadata is missing or stale. Delete ~/.agent-device/daemon.json if present and retry.';
272
+ }