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.
- package/README.md +18 -12
- package/dist/src/bin.js +32 -32
- package/dist/src/daemon.js +18 -14
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +19 -13
- package/skills/agent-device/references/permissions.md +7 -2
- package/skills/agent-device/references/session-management.md +3 -1
- package/src/__tests__/cli-close.test.ts +155 -0
- package/src/cli.ts +32 -16
- package/src/core/__tests__/capabilities.test.ts +2 -1
- package/src/core/__tests__/dispatch-open.test.ts +25 -0
- package/src/core/__tests__/open-target.test.ts +40 -1
- package/src/core/capabilities.ts +1 -1
- package/src/core/dispatch.ts +22 -0
- package/src/core/open-target.ts +14 -0
- package/src/daemon/__tests__/device-ready.test.ts +52 -0
- package/src/daemon/device-ready.ts +146 -4
- package/src/daemon/handlers/__tests__/session.test.ts +477 -0
- package/src/daemon/handlers/session.ts +196 -91
- package/src/daemon/session-store.ts +0 -2
- package/src/daemon-client.ts +118 -18
- package/src/platforms/android/__tests__/index.test.ts +118 -1
- package/src/platforms/android/index.ts +77 -47
- package/src/platforms/ios/__tests__/index.test.ts +292 -4
- package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
- package/src/platforms/ios/apps.ts +358 -0
- package/src/platforms/ios/config.ts +28 -0
- package/src/platforms/ios/devicectl.ts +134 -0
- package/src/platforms/ios/devices.ts +15 -2
- package/src/platforms/ios/index.ts +20 -455
- package/src/platforms/ios/runner-client.ts +72 -16
- package/src/platforms/ios/simulator.ts +164 -0
- package/src/utils/__tests__/args.test.ts +20 -2
- package/src/utils/__tests__/daemon-client.test.ts +21 -4
- package/src/utils/args.ts +6 -1
- package/src/utils/command-schema.ts +7 -14
- package/src/utils/interactors.ts +2 -2
- package/src/utils/timeouts.ts +9 -0
- package/src/daemon/__tests__/app-state.test.ts +0 -138
- 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
|
|
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(
|
|
58
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 {
|
|
154
|
-
const apps = await
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/daemon-client.ts
CHANGED
|
@@ -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 {
|
|
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 <
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
+
function removeFileIfExists(filePath: string): void {
|
|
92
183
|
try {
|
|
93
|
-
if (fs.existsSync(
|
|
184
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
94
185
|
} catch {
|
|
95
|
-
// Best-effort cleanup only
|
|
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
|
-
|
|
168
|
-
if (!raw) return 180000;
|
|
258
|
+
if (!raw) return 90000;
|
|
169
259
|
const parsed = Number(raw);
|
|
170
|
-
if (!Number.isFinite(parsed)) return
|
|
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
|
+
}
|