agent-device 0.4.2 → 0.5.0

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 (91) hide show
  1. package/README.md +2 -9
  2. package/dist/src/797.js +1 -1
  3. package/dist/src/bin.js +5 -5
  4. package/dist/src/daemon.js +16 -20
  5. package/package.json +7 -9
  6. package/skills/agent-device/SKILL.md +3 -6
  7. package/skills/agent-device/references/permissions.md +3 -15
  8. package/skills/agent-device/references/snapshot-refs.md +1 -4
  9. package/dist/bin/axsnapshot +0 -0
  10. package/ios-runner/AXSnapshot/Package.swift +0 -18
  11. package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
  12. package/src/__tests__/cli-close.test.ts +0 -155
  13. package/src/__tests__/cli-help.test.ts +0 -102
  14. package/src/bin.ts +0 -3
  15. package/src/cli.ts +0 -305
  16. package/src/core/__tests__/capabilities.test.ts +0 -75
  17. package/src/core/__tests__/dispatch-open.test.ts +0 -25
  18. package/src/core/__tests__/open-target.test.ts +0 -55
  19. package/src/core/capabilities.ts +0 -57
  20. package/src/core/dispatch.ts +0 -382
  21. package/src/core/open-target.ts +0 -27
  22. package/src/daemon/__tests__/device-ready.test.ts +0 -52
  23. package/src/daemon/__tests__/is-predicates.test.ts +0 -68
  24. package/src/daemon/__tests__/selectors.test.ts +0 -261
  25. package/src/daemon/__tests__/session-routing.test.ts +0 -108
  26. package/src/daemon/__tests__/session-selector.test.ts +0 -64
  27. package/src/daemon/__tests__/session-store.test.ts +0 -142
  28. package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
  29. package/src/daemon/action-utils.ts +0 -29
  30. package/src/daemon/context.ts +0 -48
  31. package/src/daemon/device-ready.ts +0 -155
  32. package/src/daemon/handlers/__tests__/find.test.ts +0 -99
  33. package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
  34. package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
  35. package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
  36. package/src/daemon/handlers/__tests__/session.test.ts +0 -820
  37. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
  38. package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
  39. package/src/daemon/handlers/find.ts +0 -324
  40. package/src/daemon/handlers/interaction.ts +0 -550
  41. package/src/daemon/handlers/parse-utils.ts +0 -8
  42. package/src/daemon/handlers/record-trace.ts +0 -154
  43. package/src/daemon/handlers/session.ts +0 -1137
  44. package/src/daemon/handlers/snapshot.ts +0 -439
  45. package/src/daemon/is-predicates.ts +0 -46
  46. package/src/daemon/selectors.ts +0 -540
  47. package/src/daemon/session-routing.ts +0 -22
  48. package/src/daemon/session-selector.ts +0 -39
  49. package/src/daemon/session-store.ts +0 -296
  50. package/src/daemon/snapshot-processing.ts +0 -131
  51. package/src/daemon/types.ts +0 -56
  52. package/src/daemon-client.ts +0 -272
  53. package/src/daemon.ts +0 -295
  54. package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
  55. package/src/platforms/android/__tests__/index.test.ts +0 -274
  56. package/src/platforms/android/devices.ts +0 -196
  57. package/src/platforms/android/index.ts +0 -784
  58. package/src/platforms/android/ui-hierarchy.ts +0 -312
  59. package/src/platforms/boot-diagnostics.ts +0 -128
  60. package/src/platforms/ios/__tests__/index.test.ts +0 -312
  61. package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
  62. package/src/platforms/ios/apps.ts +0 -358
  63. package/src/platforms/ios/ax-snapshot.ts +0 -207
  64. package/src/platforms/ios/config.ts +0 -28
  65. package/src/platforms/ios/devicectl.ts +0 -134
  66. package/src/platforms/ios/devices.ts +0 -100
  67. package/src/platforms/ios/index.ts +0 -20
  68. package/src/platforms/ios/runner-client.ts +0 -994
  69. package/src/platforms/ios/simulator.ts +0 -164
  70. package/src/utils/__tests__/args.test.ts +0 -239
  71. package/src/utils/__tests__/daemon-client.test.ts +0 -95
  72. package/src/utils/__tests__/exec.test.ts +0 -16
  73. package/src/utils/__tests__/finders.test.ts +0 -34
  74. package/src/utils/__tests__/keyed-lock.test.ts +0 -55
  75. package/src/utils/__tests__/process-identity.test.ts +0 -33
  76. package/src/utils/__tests__/retry.test.ts +0 -44
  77. package/src/utils/args.ts +0 -239
  78. package/src/utils/command-schema.ts +0 -622
  79. package/src/utils/device.ts +0 -84
  80. package/src/utils/errors.ts +0 -35
  81. package/src/utils/exec.ts +0 -339
  82. package/src/utils/finders.ts +0 -101
  83. package/src/utils/interactive.ts +0 -4
  84. package/src/utils/interactors.ts +0 -173
  85. package/src/utils/keyed-lock.ts +0 -14
  86. package/src/utils/output.ts +0 -204
  87. package/src/utils/process-identity.ts +0 -100
  88. package/src/utils/retry.ts +0 -180
  89. package/src/utils/snapshot.ts +0 -64
  90. package/src/utils/timeouts.ts +0 -9
  91. package/src/utils/version.ts +0 -26
@@ -1,272 +0,0 @@
1
- import net from 'node:net';
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { AppError } from './utils/errors.ts';
6
- import type { CommandFlags } from './core/dispatch.ts';
7
- import { runCmdDetached } from './utils/exec.ts';
8
- import { findProjectRoot, readVersion } from './utils/version.ts';
9
- import {
10
- isAgentDeviceDaemonProcess,
11
- stopProcessForTakeover,
12
- } from './utils/process-identity.ts';
13
-
14
- export type DaemonRequest = {
15
- token: string;
16
- session: string;
17
- command: string;
18
- positionals: string[];
19
- flags?: CommandFlags;
20
- };
21
-
22
- export type DaemonResponse =
23
- | { ok: true; data?: Record<string, unknown> }
24
- | { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
25
-
26
- type DaemonInfo = {
27
- port: number;
28
- token: string;
29
- pid: number;
30
- version?: string;
31
- processStartTime?: string;
32
- };
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
-
45
- const baseDir = path.join(os.homedir(), '.agent-device');
46
- const infoPath = path.join(baseDir, 'daemon.json');
47
- const lockPath = path.join(baseDir, 'daemon.lock');
48
- const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
49
- const DAEMON_STARTUP_TIMEOUT_MS = 5000;
50
- const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
51
- const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000;
52
-
53
- export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
54
- const info = await ensureDaemon();
55
- const request = { ...req, token: info.token };
56
- return await sendRequest(info, request);
57
- }
58
-
59
- async function ensureDaemon(): Promise<DaemonInfo> {
60
- const existing = readDaemonInfo();
61
- const localVersion = readVersion();
62
- const existingReachable = existing ? await canConnect(existing) : false;
63
- if (existing && existing.version === localVersion && existingReachable) return existing;
64
- if (existing && (existing.version !== localVersion || !existingReachable)) {
65
- await stopDaemonProcessForTakeover(existing);
66
- removeDaemonInfo();
67
- }
68
-
69
- cleanupStaleDaemonLockIfSafe();
70
- await startDaemon();
71
- const started = await waitForDaemonInfo(DAEMON_STARTUP_TIMEOUT_MS);
72
- if (started) return started;
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> {
89
- const start = Date.now();
90
- while (Date.now() - start < timeoutMs) {
91
- const info = readDaemonInfo();
92
- if (info && (await canConnect(info))) return info;
93
- await new Promise((resolve) => setTimeout(resolve, 100));
94
- }
95
- return null;
96
- }
97
-
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,
114
- });
115
- removeDaemonLock();
116
- return true;
117
- }
118
-
119
- async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
120
- await stopProcessForTakeover(info.pid, {
121
- termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
122
- killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
123
- expectedStartTime: info.processStartTime,
124
- });
125
- }
126
-
127
- function readDaemonInfo(): DaemonInfo | 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;
175
- try {
176
- return JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
177
- } catch {
178
- return null;
179
- }
180
- }
181
-
182
- function removeFileIfExists(filePath: string): void {
183
- try {
184
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
185
- } catch {
186
- // Best-effort cleanup only.
187
- }
188
- }
189
-
190
- async function canConnect(info: DaemonInfo): Promise<boolean> {
191
- return new Promise((resolve) => {
192
- const socket = net.createConnection({ host: '127.0.0.1', port: info.port }, () => {
193
- socket.destroy();
194
- resolve(true);
195
- });
196
- socket.on('error', () => {
197
- resolve(false);
198
- });
199
- });
200
- }
201
-
202
- async function startDaemon(): Promise<void> {
203
- const root = findProjectRoot();
204
- const distPath = path.join(root, 'dist', 'src', 'daemon.js');
205
- const srcPath = path.join(root, 'src', 'daemon.ts');
206
-
207
- const hasDist = fs.existsSync(distPath);
208
- const hasSrc = fs.existsSync(srcPath);
209
- if (!hasDist && !hasSrc) {
210
- throw new AppError('COMMAND_FAILED', 'Daemon entry not found', { distPath, srcPath });
211
- }
212
- const runningFromSource = process.execArgv.includes('--experimental-strip-types');
213
- const useSrc = runningFromSource ? hasSrc : !hasDist && hasSrc;
214
- const args = useSrc ? ['--experimental-strip-types', srcPath] : [distPath];
215
-
216
- runCmdDetached(process.execPath, args);
217
- }
218
-
219
- async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<DaemonResponse> {
220
- return new Promise((resolve, reject) => {
221
- const socket = net.createConnection({ host: '127.0.0.1', port: info.port }, () => {
222
- socket.write(`${JSON.stringify(req)}\n`);
223
- });
224
- const timeout = setTimeout(() => {
225
- socket.destroy();
226
- reject(
227
- new AppError('COMMAND_FAILED', 'Daemon request timed out', { timeoutMs: REQUEST_TIMEOUT_MS }),
228
- );
229
- }, REQUEST_TIMEOUT_MS);
230
-
231
- let buffer = '';
232
- socket.setEncoding('utf8');
233
- socket.on('data', (chunk) => {
234
- buffer += chunk;
235
- const idx = buffer.indexOf('\n');
236
- if (idx === -1) return;
237
- const line = buffer.slice(0, idx).trim();
238
- if (!line) return;
239
- try {
240
- const response = JSON.parse(line) as DaemonResponse;
241
- socket.end();
242
- clearTimeout(timeout);
243
- resolve(response);
244
- } catch (err) {
245
- clearTimeout(timeout);
246
- reject(err);
247
- }
248
- });
249
-
250
- socket.on('error', (err) => {
251
- clearTimeout(timeout);
252
- reject(err);
253
- });
254
- });
255
- }
256
-
257
- export function resolveDaemonRequestTimeoutMs(raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS): number {
258
- if (!raw) return 90000;
259
- const parsed = Number(raw);
260
- if (!Number.isFinite(parsed)) return 90000;
261
- return Math.max(1000, Math.floor(parsed));
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
- }
package/src/daemon.ts DELETED
@@ -1,295 +0,0 @@
1
- import net from 'node:net';
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import crypto from 'node:crypto';
6
- import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
7
- import { isCommandSupportedOnDevice } from './core/capabilities.ts';
8
- import { asAppError, AppError } from './utils/errors.ts';
9
- import { readVersion } from './utils/version.ts';
10
- import { stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts';
11
- import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
12
- import { SessionStore } from './daemon/session-store.ts';
13
- import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
14
- import { handleSessionCommands } from './daemon/handlers/session.ts';
15
- import { handleSnapshotCommands } from './daemon/handlers/snapshot.ts';
16
- import { handleFindCommands } from './daemon/handlers/find.ts';
17
- import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
18
- import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
19
- import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
20
- import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
21
- import {
22
- isAgentDeviceDaemonProcess,
23
- readProcessStartTime,
24
- } from './utils/process-identity.ts';
25
-
26
- const baseDir = path.join(os.homedir(), '.agent-device');
27
- const infoPath = path.join(baseDir, 'daemon.json');
28
- const lockPath = path.join(baseDir, 'daemon.lock');
29
- const logPath = path.join(baseDir, 'daemon.log');
30
- const sessionsDir = path.join(baseDir, 'sessions');
31
- const sessionStore = new SessionStore(sessionsDir);
32
- const version = readVersion();
33
- const token = crypto.randomBytes(24).toString('hex');
34
- const selectorValidationExemptCommands = new Set(['session_list', 'devices']);
35
-
36
- type DaemonLockInfo = {
37
- pid: number;
38
- version: string;
39
- startedAt: number;
40
- processStartTime?: string;
41
- };
42
-
43
- const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined;
44
-
45
- function contextFromFlags(
46
- flags: CommandFlags | undefined,
47
- appBundleId?: string,
48
- traceLogPath?: string,
49
- ): DaemonCommandContext {
50
- return contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath);
51
- }
52
-
53
- async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
54
- if (req.token !== token) {
55
- return { ok: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } };
56
- }
57
-
58
- const command = req.command;
59
- const sessionName = resolveEffectiveSessionName(req, sessionStore);
60
- const existingSession = sessionStore.get(sessionName);
61
- if (existingSession && !selectorValidationExemptCommands.has(command)) {
62
- assertSessionSelectorMatches(existingSession, req.flags);
63
- }
64
-
65
- const sessionResponse = await handleSessionCommands({
66
- req,
67
- sessionName,
68
- logPath,
69
- sessionStore,
70
- invoke: handleRequest,
71
- });
72
- if (sessionResponse) return sessionResponse;
73
-
74
- const snapshotResponse = await handleSnapshotCommands({
75
- req,
76
- sessionName,
77
- logPath,
78
- sessionStore,
79
- });
80
- if (snapshotResponse) return snapshotResponse;
81
-
82
- const recordTraceResponse = await handleRecordTraceCommands({
83
- req,
84
- sessionName,
85
- sessionStore,
86
- });
87
- if (recordTraceResponse) return recordTraceResponse;
88
-
89
- const findResponse = await handleFindCommands({
90
- req,
91
- sessionName,
92
- logPath,
93
- sessionStore,
94
- invoke: handleRequest,
95
- });
96
- if (findResponse) return findResponse;
97
-
98
- const interactionResponse = await handleInteractionCommands({
99
- req,
100
- sessionName,
101
- sessionStore,
102
- contextFromFlags,
103
- });
104
- if (interactionResponse) return interactionResponse;
105
-
106
-
107
- const session = sessionStore.get(sessionName);
108
- if (!session) {
109
- return {
110
- ok: false,
111
- error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
112
- };
113
- }
114
-
115
- if (!isCommandSupportedOnDevice(command, session.device)) {
116
- return {
117
- ok: false,
118
- error: { code: 'UNSUPPORTED_OPERATION', message: `${command} is not supported on this device` },
119
- };
120
- }
121
-
122
- const data = await dispatchCommand(session.device, command, req.positionals ?? [], req.flags?.out, {
123
- ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
124
- });
125
- sessionStore.recordAction(session, {
126
- command,
127
- positionals: req.positionals ?? [],
128
- flags: req.flags ?? {},
129
- result: data ?? {},
130
- });
131
- return { ok: true, data: data ?? {} };
132
- }
133
-
134
- function writeInfo(port: number): void {
135
- if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
136
- fs.writeFileSync(logPath, '');
137
- fs.writeFileSync(
138
- infoPath,
139
- JSON.stringify({ port, token, pid: process.pid, version, processStartTime: daemonProcessStartTime }, null, 2),
140
- {
141
- mode: 0o600,
142
- },
143
- );
144
- }
145
-
146
- function removeInfo(): void {
147
- if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
148
- }
149
-
150
- function readLockInfo(): DaemonLockInfo | null {
151
- if (!fs.existsSync(lockPath)) return null;
152
- try {
153
- const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as DaemonLockInfo;
154
- if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
155
- return parsed;
156
- } catch {
157
- return null;
158
- }
159
- }
160
-
161
- function acquireDaemonLock(): boolean {
162
- if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
163
- const lockData: DaemonLockInfo = {
164
- pid: process.pid,
165
- version,
166
- startedAt: Date.now(),
167
- processStartTime: daemonProcessStartTime,
168
- };
169
- const payload = JSON.stringify(lockData, null, 2);
170
-
171
- const tryWriteLock = (): boolean => {
172
- try {
173
- fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
174
- return true;
175
- } catch (err) {
176
- if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
177
- throw err;
178
- }
179
- };
180
-
181
- if (tryWriteLock()) return true;
182
- const existing = readLockInfo();
183
- if (
184
- existing?.pid
185
- && existing.pid !== process.pid
186
- && isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime)
187
- ) {
188
- return false;
189
- }
190
- // Best-effort stale-lock cleanup: another process may win the race between unlink and re-create.
191
- // We rely on the subsequent write with `wx` to enforce single-writer semantics.
192
- try {
193
- fs.unlinkSync(lockPath);
194
- } catch {
195
- // ignore
196
- }
197
- return tryWriteLock();
198
- }
199
-
200
- function releaseDaemonLock(): void {
201
- const existing = readLockInfo();
202
- if (existing && existing.pid !== process.pid) return;
203
- try {
204
- if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
205
- } catch {
206
- // ignore
207
- }
208
- }
209
-
210
- function start(): void {
211
- if (!acquireDaemonLock()) {
212
- process.stderr.write('Daemon lock is held by another process; exiting.\n');
213
- process.exit(0);
214
- return;
215
- }
216
-
217
- const server = net.createServer((socket) => {
218
- let buffer = '';
219
- socket.setEncoding('utf8');
220
- socket.on('data', async (chunk) => {
221
- buffer += chunk;
222
- let idx = buffer.indexOf('\n');
223
- while (idx !== -1) {
224
- const line = buffer.slice(0, idx).trim();
225
- buffer = buffer.slice(idx + 1);
226
- if (line.length === 0) {
227
- idx = buffer.indexOf('\n');
228
- continue;
229
- }
230
- let response: DaemonResponse;
231
- try {
232
- const req = JSON.parse(line) as DaemonRequest;
233
- response = await handleRequest(req);
234
- } catch (err) {
235
- const appErr = asAppError(err);
236
- response = {
237
- ok: false,
238
- error: { code: appErr.code, message: appErr.message, details: appErr.details },
239
- };
240
- }
241
- socket.write(`${JSON.stringify(response)}\n`);
242
- idx = buffer.indexOf('\n');
243
- }
244
- });
245
- });
246
-
247
- server.listen(0, '127.0.0.1', () => {
248
- const address = server.address();
249
- if (typeof address === 'object' && address?.port) {
250
- writeInfo(address.port);
251
- process.stdout.write(`AGENT_DEVICE_DAEMON_PORT=${address.port}\n`);
252
- }
253
- });
254
-
255
- let shuttingDown = false;
256
- const closeServer = async (): Promise<void> => {
257
- await new Promise<void>((resolve) => {
258
- try {
259
- server.close(() => resolve());
260
- } catch {
261
- resolve();
262
- }
263
- });
264
- };
265
- const shutdown = async () => {
266
- if (shuttingDown) return;
267
- shuttingDown = true;
268
- await closeServer();
269
- const sessionsToStop = sessionStore.toArray();
270
- for (const session of sessionsToStop) {
271
- sessionStore.writeSessionLog(session);
272
- }
273
- await stopAllIosRunnerSessions();
274
- removeInfo();
275
- releaseDaemonLock();
276
- process.exit(0);
277
- };
278
-
279
- process.on('SIGINT', () => {
280
- void shutdown();
281
- });
282
- process.on('SIGTERM', () => {
283
- void shutdown();
284
- });
285
- process.on('SIGHUP', () => {
286
- void shutdown();
287
- });
288
- process.on('uncaughtException', (err) => {
289
- const appErr = err instanceof AppError ? err : asAppError(err);
290
- process.stderr.write(`Daemon error: ${appErr.message}\n`);
291
- void shutdown();
292
- });
293
- }
294
-
295
- start();
@@ -1,59 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
4
- import { AppError } from '../../utils/errors.ts';
5
-
6
- test('classifyBootFailure maps timeout errors', () => {
7
- const reason = classifyBootFailure({
8
- message: 'bootstatus timed out after 120s',
9
- context: { platform: 'ios', phase: 'boot' },
10
- });
11
- assert.equal(reason, 'IOS_BOOT_TIMEOUT');
12
- });
13
-
14
- test('classifyBootFailure maps adb offline errors', () => {
15
- const reason = classifyBootFailure({
16
- stderr: 'error: device offline',
17
- context: { platform: 'android', phase: 'transport' },
18
- });
19
- assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
20
- });
21
-
22
- test('classifyBootFailure maps tool missing from AppError code (android)', () => {
23
- const reason = classifyBootFailure({
24
- error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
25
- context: { platform: 'android', phase: 'transport' },
26
- });
27
- assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
28
- });
29
-
30
- test('classifyBootFailure maps tool missing from AppError code (ios)', () => {
31
- const reason = classifyBootFailure({
32
- error: new AppError('TOOL_MISSING', 'xcrun not found in PATH'),
33
- context: { platform: 'ios', phase: 'boot' },
34
- });
35
- assert.equal(reason, 'IOS_TOOL_MISSING');
36
- });
37
-
38
- test('classifyBootFailure reads stderr from AppError details', () => {
39
- const reason = classifyBootFailure({
40
- error: new AppError('COMMAND_FAILED', 'adb failed', {
41
- stderr: 'error: device unauthorized',
42
- }),
43
- context: { platform: 'android', phase: 'transport' },
44
- });
45
- assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
46
- });
47
-
48
- test('bootFailureHint returns actionable guidance', () => {
49
- const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
50
- assert.equal(hint.includes('xcodebuild logs'), true);
51
- });
52
-
53
- test('connect phase does not classify non-timeout errors as connect timeout', () => {
54
- const reason = classifyBootFailure({
55
- message: 'Runner returned malformed JSON payload',
56
- context: { platform: 'ios', phase: 'connect' },
57
- });
58
- assert.equal(reason, 'BOOT_COMMAND_FAILED');
59
- });