agent-device 0.4.0 → 0.4.1

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.
@@ -1,6 +1,6 @@
1
1
  import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
2
2
  import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
3
- import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
3
+ import { runIosRunnerCommand, stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
4
4
  import { snapshotAndroid } from '../../platforms/android/index.ts';
5
5
  import {
6
6
  attachRefs,
@@ -78,45 +78,47 @@ export async function handleSnapshotCommands(params: {
78
78
  }
79
79
  snapshotScope = resolved;
80
80
  }
81
- const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
82
- ...contextFromFlags(
83
- logPath,
84
- { ...req.flags, snapshotScope },
85
- appBundleId,
86
- session?.trace?.outPath,
87
- ),
88
- })) as {
89
- nodes?: RawSnapshotNode[];
90
- truncated?: boolean;
91
- backend?: 'ax' | 'xctest' | 'android';
92
- };
93
- const rawNodes = data?.nodes ?? [];
94
- const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
95
- const snapshot: SnapshotState = {
96
- nodes,
97
- truncated: data?.truncated,
98
- createdAt: Date.now(),
99
- backend: data?.backend,
100
- };
101
- const nextSession: SessionState = session
102
- ? { ...session, snapshot }
103
- : { name: sessionName, device, createdAt: Date.now(), appBundleId, snapshot, actions: [] };
104
- recordIfSession(sessionStore, nextSession, req, {
105
- nodes: nodes.length,
106
- truncated: data?.truncated ?? false,
107
- });
108
- sessionStore.set(sessionName, nextSession);
109
- return {
110
- ok: true,
111
- data: {
81
+ return await withSessionlessRunnerCleanup(session, device, async () => {
82
+ const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
83
+ ...contextFromFlags(
84
+ logPath,
85
+ { ...req.flags, snapshotScope },
86
+ appBundleId,
87
+ session?.trace?.outPath,
88
+ ),
89
+ })) as {
90
+ nodes?: RawSnapshotNode[];
91
+ truncated?: boolean;
92
+ backend?: 'ax' | 'xctest' | 'android';
93
+ };
94
+ const rawNodes = data?.nodes ?? [];
95
+ const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
96
+ const snapshot: SnapshotState = {
112
97
  nodes,
98
+ truncated: data?.truncated,
99
+ createdAt: Date.now(),
100
+ backend: data?.backend,
101
+ };
102
+ const nextSession: SessionState = session
103
+ ? { ...session, snapshot }
104
+ : { name: sessionName, device, createdAt: Date.now(), appBundleId, snapshot, actions: [] };
105
+ recordIfSession(sessionStore, nextSession, req, {
106
+ nodes: nodes.length,
113
107
  truncated: data?.truncated ?? false,
114
- appName: nextSession.appBundleId
115
- ? (nextSession.appName ?? nextSession.appBundleId)
116
- : undefined,
117
- appBundleId: nextSession.appBundleId,
118
- },
119
- };
108
+ });
109
+ sessionStore.set(sessionName, nextSession);
110
+ return {
111
+ ok: true,
112
+ data: {
113
+ nodes,
114
+ truncated: data?.truncated ?? false,
115
+ appName: nextSession.appBundleId
116
+ ? (nextSession.appName ?? nextSession.appBundleId)
117
+ : undefined,
118
+ appBundleId: nextSession.appBundleId,
119
+ },
120
+ };
121
+ });
120
122
  }
121
123
 
122
124
  if (command === 'wait') {
@@ -140,125 +142,127 @@ export async function handleSnapshotCommands(params: {
140
142
  error: { code: 'UNSUPPORTED_OPERATION', message: 'wait is not supported on this device' },
141
143
  };
142
144
  }
143
- let text: string;
144
- let timeoutMs: number | null;
145
- if (parsed.kind === 'selector') {
146
- const timeout = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS;
147
- const start = Date.now();
148
- while (Date.now() - start < timeout) {
149
- const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
150
- ...contextFromFlags(
151
- logPath,
152
- {
153
- ...req.flags,
154
- snapshotInteractiveOnly: false,
155
- snapshotCompact: false,
156
- },
157
- session?.appBundleId,
158
- session?.trace?.outPath,
159
- ),
160
- })) as {
161
- nodes?: RawSnapshotNode[];
162
- truncated?: boolean;
163
- backend?: 'ax' | 'xctest' | 'android';
164
- };
165
- const rawNodes = data?.nodes ?? [];
166
- const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
167
- if (session) {
168
- session.snapshot = {
169
- nodes,
170
- truncated: data?.truncated,
171
- createdAt: Date.now(),
172
- backend: data?.backend,
145
+ return await withSessionlessRunnerCleanup(session, device, async () => {
146
+ let text: string;
147
+ let timeoutMs: number | null;
148
+ if (parsed.kind === 'selector') {
149
+ const timeout = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS;
150
+ const start = Date.now();
151
+ while (Date.now() - start < timeout) {
152
+ const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
153
+ ...contextFromFlags(
154
+ logPath,
155
+ {
156
+ ...req.flags,
157
+ snapshotInteractiveOnly: false,
158
+ snapshotCompact: false,
159
+ },
160
+ session?.appBundleId,
161
+ session?.trace?.outPath,
162
+ ),
163
+ })) as {
164
+ nodes?: RawSnapshotNode[];
165
+ truncated?: boolean;
166
+ backend?: 'ax' | 'xctest' | 'android';
173
167
  };
174
- sessionStore.set(sessionName, session);
175
- }
176
- const match = findSelectorChainMatch(nodes, parsed.selector, { platform: device.platform });
177
- if (match) {
178
- recordIfSession(sessionStore, session, req, {
179
- selector: match.selector.raw,
180
- waitedMs: Date.now() - start,
181
- });
182
- return {
183
- ok: true,
184
- data: {
168
+ const rawNodes = data?.nodes ?? [];
169
+ const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
170
+ if (session) {
171
+ session.snapshot = {
172
+ nodes,
173
+ truncated: data?.truncated,
174
+ createdAt: Date.now(),
175
+ backend: data?.backend,
176
+ };
177
+ sessionStore.set(sessionName, session);
178
+ }
179
+ const match = findSelectorChainMatch(nodes, parsed.selector, { platform: device.platform });
180
+ if (match) {
181
+ recordIfSession(sessionStore, session, req, {
185
182
  selector: match.selector.raw,
186
183
  waitedMs: Date.now() - start,
187
- },
188
- };
184
+ });
185
+ return {
186
+ ok: true,
187
+ data: {
188
+ selector: match.selector.raw,
189
+ waitedMs: Date.now() - start,
190
+ },
191
+ };
192
+ }
193
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
189
194
  }
190
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
191
- }
192
- return {
193
- ok: false,
194
- error: {
195
- code: 'COMMAND_FAILED',
196
- message: `wait timed out for selector: ${parsed.selectorExpression}`,
197
- },
198
- };
199
- } else if (parsed.kind === 'ref') {
200
- if (!session?.snapshot) {
201
- return {
202
- ok: false,
203
- error: {
204
- code: 'INVALID_ARGS',
205
- message: 'Ref wait requires an existing snapshot in session.',
206
- },
207
- };
208
- }
209
- const ref = normalizeRef(parsed.rawRef);
210
- if (!ref) {
211
- return {
212
- ok: false,
213
- error: { code: 'INVALID_ARGS', message: `Invalid ref: ${parsed.rawRef}` },
214
- };
215
- }
216
- const node = findNodeByRef(session.snapshot.nodes, ref);
217
- const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
218
- if (!resolved) {
219
195
  return {
220
196
  ok: false,
221
197
  error: {
222
198
  code: 'COMMAND_FAILED',
223
- message: `Ref ${parsed.rawRef} not found or has no label`,
199
+ message: `wait timed out for selector: ${parsed.selectorExpression}`,
224
200
  },
225
201
  };
226
- }
227
- text = resolved;
228
- timeoutMs = parsed.timeoutMs;
229
- } else {
230
- text = parsed.text;
231
- timeoutMs = parsed.timeoutMs;
232
- }
233
- if (!text) {
234
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } };
235
- }
236
- const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS;
237
- const start = Date.now();
238
- while (Date.now() - start < timeout) {
239
- if (device.platform === 'ios') {
240
- const result = (await runIosRunnerCommand(
241
- device,
242
- { command: 'findText', text, appBundleId: session?.appBundleId },
243
- { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
244
- )) as { found?: boolean };
245
- if (result?.found) {
246
- recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start });
247
- return { ok: true, data: { text, waitedMs: Date.now() - start } };
202
+ } else if (parsed.kind === 'ref') {
203
+ if (!session?.snapshot) {
204
+ return {
205
+ ok: false,
206
+ error: {
207
+ code: 'INVALID_ARGS',
208
+ message: 'Ref wait requires an existing snapshot in session.',
209
+ },
210
+ };
211
+ }
212
+ const ref = normalizeRef(parsed.rawRef);
213
+ if (!ref) {
214
+ return {
215
+ ok: false,
216
+ error: { code: 'INVALID_ARGS', message: `Invalid ref: ${parsed.rawRef}` },
217
+ };
218
+ }
219
+ const node = findNodeByRef(session.snapshot.nodes, ref);
220
+ const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
221
+ if (!resolved) {
222
+ return {
223
+ ok: false,
224
+ error: {
225
+ code: 'COMMAND_FAILED',
226
+ message: `Ref ${parsed.rawRef} not found or has no label`,
227
+ },
228
+ };
248
229
  }
249
- } else if (device.platform === 'android') {
250
- const androidResult = await snapshotAndroid(device, { scope: text });
251
- if (findNodeByLabel(attachRefs(androidResult.nodes ?? []), text)) {
252
- recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start });
253
- return { ok: true, data: { text, waitedMs: Date.now() - start } };
230
+ text = resolved;
231
+ timeoutMs = parsed.timeoutMs;
232
+ } else {
233
+ text = parsed.text;
234
+ timeoutMs = parsed.timeoutMs;
235
+ }
236
+ if (!text) {
237
+ return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } };
238
+ }
239
+ const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS;
240
+ const start = Date.now();
241
+ while (Date.now() - start < timeout) {
242
+ if (device.platform === 'ios') {
243
+ const result = (await runIosRunnerCommand(
244
+ device,
245
+ { command: 'findText', text, appBundleId: session?.appBundleId },
246
+ { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
247
+ )) as { found?: boolean };
248
+ if (result?.found) {
249
+ recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start });
250
+ return { ok: true, data: { text, waitedMs: Date.now() - start } };
251
+ }
252
+ } else if (device.platform === 'android') {
253
+ const androidResult = await snapshotAndroid(device, { scope: text });
254
+ if (findNodeByLabel(attachRefs(androidResult.nodes ?? []), text)) {
255
+ recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start });
256
+ return { ok: true, data: { text, waitedMs: Date.now() - start } };
257
+ }
254
258
  }
259
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
255
260
  }
256
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
257
- }
258
- return {
259
- ok: false,
260
- error: { code: 'COMMAND_FAILED', message: `wait timed out for text: ${text}` },
261
- };
261
+ return {
262
+ ok: false,
263
+ error: { code: 'COMMAND_FAILED', message: `wait timed out for text: ${text}` },
264
+ };
265
+ });
262
266
  }
263
267
 
264
268
  if (command === 'alert') {
@@ -273,37 +277,39 @@ export async function handleSnapshotCommands(params: {
273
277
  },
274
278
  };
275
279
  }
276
- if (action === 'wait') {
277
- const timeout = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS;
278
- const start = Date.now();
279
- while (Date.now() - start < timeout) {
280
- try {
281
- const data = await runIosRunnerCommand(
282
- device,
283
- { command: 'alert', action: 'get', appBundleId: session?.appBundleId },
284
- { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
285
- );
286
- recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
287
- return { ok: true, data };
288
- } catch {
289
- // keep waiting
280
+ return await withSessionlessRunnerCleanup(session, device, async () => {
281
+ if (action === 'wait') {
282
+ const timeout = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS;
283
+ const start = Date.now();
284
+ while (Date.now() - start < timeout) {
285
+ try {
286
+ const data = await runIosRunnerCommand(
287
+ device,
288
+ { command: 'alert', action: 'get', appBundleId: session?.appBundleId },
289
+ { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
290
+ );
291
+ recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
292
+ return { ok: true, data };
293
+ } catch {
294
+ // keep waiting
295
+ }
296
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
290
297
  }
291
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
298
+ return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } };
292
299
  }
293
- return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } };
294
- }
295
- const data = await runIosRunnerCommand(
296
- device,
297
- {
298
- command: 'alert',
299
- action:
300
- action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get',
301
- appBundleId: session?.appBundleId,
302
- },
303
- { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
304
- );
305
- recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
306
- return { ok: true, data };
300
+ const data = await runIosRunnerCommand(
301
+ device,
302
+ {
303
+ command: 'alert',
304
+ action:
305
+ action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get',
306
+ appBundleId: session?.appBundleId,
307
+ },
308
+ { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
309
+ );
310
+ recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
311
+ return { ok: true, data };
312
+ });
307
313
  }
308
314
 
309
315
  if (command === 'settings') {
@@ -328,18 +334,20 @@ export async function handleSnapshotCommands(params: {
328
334
  },
329
335
  };
330
336
  }
331
- const appBundleId = session?.appBundleId;
332
- const data = await dispatchCommand(
333
- device,
334
- 'settings',
335
- [setting, state, appBundleId ?? ''],
336
- req.flags?.out,
337
- {
338
- ...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath),
339
- },
340
- );
341
- recordIfSession(sessionStore, session, req, data ?? { setting, state });
342
- return { ok: true, data: data ?? { setting, state } };
337
+ return await withSessionlessRunnerCleanup(session, device, async () => {
338
+ const appBundleId = session?.appBundleId;
339
+ const data = await dispatchCommand(
340
+ device,
341
+ 'settings',
342
+ [setting, state, appBundleId ?? ''],
343
+ req.flags?.out,
344
+ {
345
+ ...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath),
346
+ },
347
+ );
348
+ recordIfSession(sessionStore, session, req, data ?? { setting, state });
349
+ return { ok: true, data: data ?? { setting, state } };
350
+ });
343
351
  }
344
352
 
345
353
  return null;
@@ -398,6 +406,23 @@ async function resolveSessionDevice(
398
406
  return { session, device };
399
407
  }
400
408
 
409
+ async function withSessionlessRunnerCleanup<T>(
410
+ session: SessionState | undefined,
411
+ device: SessionState['device'],
412
+ task: () => Promise<T>,
413
+ ): Promise<T> {
414
+ const shouldCleanupSessionlessIosRunner = !session && device.platform === 'ios';
415
+ try {
416
+ return await task();
417
+ } finally {
418
+ // Sessionless iOS commands intentionally stop the runner to avoid leaked xcodebuild processes.
419
+ // For multi-command flows, keep an active session via `open` so the runner can be reused.
420
+ if (shouldCleanupSessionlessIosRunner) {
421
+ await stopIosRunnerSession(device.id);
422
+ }
423
+ }
424
+ }
425
+
401
426
  function recordIfSession(
402
427
  sessionStore: SessionStore,
403
428
  session: SessionState | undefined,
@@ -49,6 +49,9 @@ export class SessionStore {
49
49
  if (entry.flags?.noRecord) return;
50
50
  if (entry.flags?.saveScript) {
51
51
  session.recordSession = true;
52
+ if (typeof entry.flags.saveScript === 'string') {
53
+ session.saveScriptPath = SessionStore.expandHome(entry.flags.saveScript);
54
+ }
52
55
  }
53
56
  session.actions.push({
54
57
  ts: Date.now(),
@@ -62,10 +65,9 @@ export class SessionStore {
62
65
  writeSessionLog(session: SessionState): void {
63
66
  try {
64
67
  if (!session.recordSession) return;
65
- if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true });
66
- const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
67
- const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
68
- const scriptPath = path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`);
68
+ const scriptPath = this.resolveScriptPath(session);
69
+ const scriptDir = path.dirname(scriptPath);
70
+ if (!fs.existsSync(scriptDir)) fs.mkdirSync(scriptDir, { recursive: true });
69
71
  const script = formatScript(session, this.buildOptimizedActions(session));
70
72
  fs.writeFileSync(scriptPath, script);
71
73
  } catch {
@@ -86,6 +88,16 @@ export class SessionStore {
86
88
  return path.resolve(filePath);
87
89
  }
88
90
 
91
+ private resolveScriptPath(session: SessionState): string {
92
+ if (session.saveScriptPath) {
93
+ return SessionStore.expandHome(session.saveScriptPath);
94
+ }
95
+ if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true });
96
+ const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
97
+ const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
98
+ return path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`);
99
+ }
100
+
89
101
  private buildOptimizedActions(session: SessionState): SessionAction[] {
90
102
  const optimized: SessionAction[] = [];
91
103
  for (const action of session.actions) {
@@ -27,6 +27,7 @@ export type SessionState = {
27
27
  startedAt: number;
28
28
  };
29
29
  recordSession?: boolean;
30
+ saveScriptPath?: string;
30
31
  actions: SessionAction[];
31
32
  recording?: {
32
33
  platform: 'ios' | 'android';
@@ -48,7 +49,7 @@ export type SessionAction = {
48
49
  snapshotScope?: string;
49
50
  snapshotRaw?: boolean;
50
51
  snapshotBackend?: 'ax' | 'xctest';
51
- saveScript?: boolean;
52
+ saveScript?: boolean | string;
52
53
  noRecord?: boolean;
53
54
  };
54
55
  result?: Record<string, unknown>;
@@ -6,6 +6,7 @@ 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
10
 
10
11
  export type DaemonRequest = {
11
12
  token: string;
@@ -19,12 +20,20 @@ export type DaemonResponse =
19
20
  | { ok: true; data?: Record<string, unknown> }
20
21
  | { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
21
22
 
22
- type DaemonInfo = { port: number; token: string; pid: number; version?: string };
23
+ type DaemonInfo = {
24
+ port: number;
25
+ token: string;
26
+ pid: number;
27
+ version?: string;
28
+ processStartTime?: string;
29
+ };
23
30
 
24
31
  const baseDir = path.join(os.homedir(), '.agent-device');
25
32
  const infoPath = path.join(baseDir, 'daemon.json');
26
- const REQUEST_TIMEOUT_MS = resolveRequestTimeoutMs();
33
+ const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
27
34
  const DAEMON_STARTUP_TIMEOUT_MS = 5000;
35
+ const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
36
+ const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000;
28
37
 
29
38
  export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
30
39
  const info = await ensureDaemon();
@@ -38,6 +47,7 @@ async function ensureDaemon(): Promise<DaemonInfo> {
38
47
  const existingReachable = existing ? await canConnect(existing) : false;
39
48
  if (existing && existing.version === localVersion && existingReachable) return existing;
40
49
  if (existing && (existing.version !== localVersion || !existingReachable)) {
50
+ await stopDaemonProcessForTakeover(existing);
41
51
  removeDaemonInfo();
42
52
  }
43
53
 
@@ -56,12 +66,23 @@ async function ensureDaemon(): Promise<DaemonInfo> {
56
66
  });
57
67
  }
58
68
 
69
+ async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
70
+ await stopProcessForTakeover(info.pid, {
71
+ termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
72
+ killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
73
+ expectedStartTime: info.processStartTime,
74
+ });
75
+ }
76
+
59
77
  function readDaemonInfo(): DaemonInfo | null {
60
78
  if (!fs.existsSync(infoPath)) return null;
61
79
  try {
62
80
  const data = JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo;
63
81
  if (!data.port || !data.token) return null;
64
- return data;
82
+ return {
83
+ ...data,
84
+ pid: Number.isInteger(data.pid) && data.pid > 0 ? data.pid : 0,
85
+ };
65
86
  } catch {
66
87
  return null;
67
88
  }
@@ -142,10 +163,10 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
142
163
  });
143
164
  }
144
165
 
145
- function resolveRequestTimeoutMs(): number {
146
- const raw = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS;
147
- if (!raw) return 60000;
166
+ 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;
148
169
  const parsed = Number(raw);
149
- if (!Number.isFinite(parsed)) return 60000;
170
+ if (!Number.isFinite(parsed)) return 180000;
150
171
  return Math.max(1000, Math.floor(parsed));
151
172
  }