agent-device 0.1.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/bin/agent-device.mjs +14 -0
  4. package/bin/axsnapshot +0 -0
  5. package/dist/src/861.js +1 -0
  6. package/dist/src/bin.js +50 -0
  7. package/dist/src/daemon.js +5 -0
  8. package/ios-runner/AXSnapshot/Package.swift +18 -0
  9. package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +167 -0
  10. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.swift +17 -0
  11. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json +11 -0
  12. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json +36 -0
  13. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/logo.jpg +0 -0
  14. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json +6 -0
  15. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/Contents.json +21 -0
  16. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/logo.jpg +0 -0
  17. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/Contents.json +21 -0
  18. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/powered-by.png +0 -0
  19. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner/ContentView.swift +34 -0
  20. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +461 -0
  21. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  22. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +102 -0
  23. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +696 -0
  24. package/ios-runner/README.md +11 -0
  25. package/package.json +66 -0
  26. package/src/bin.ts +3 -0
  27. package/src/cli.ts +160 -0
  28. package/src/core/dispatch.ts +259 -0
  29. package/src/daemon-client.ts +166 -0
  30. package/src/daemon.ts +842 -0
  31. package/src/platforms/android/devices.ts +59 -0
  32. package/src/platforms/android/index.ts +442 -0
  33. package/src/platforms/ios/ax-snapshot.ts +154 -0
  34. package/src/platforms/ios/devices.ts +65 -0
  35. package/src/platforms/ios/index.ts +218 -0
  36. package/src/platforms/ios/runner-client.ts +534 -0
  37. package/src/utils/args.ts +175 -0
  38. package/src/utils/device.ts +84 -0
  39. package/src/utils/errors.ts +35 -0
  40. package/src/utils/exec.ts +229 -0
  41. package/src/utils/interactive.ts +4 -0
  42. package/src/utils/interactors.ts +72 -0
  43. package/src/utils/output.ts +146 -0
  44. package/src/utils/snapshot.ts +63 -0
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "agent-device",
3
+ "version": "0.1.0",
4
+ "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
5
+ "license": "MIT",
6
+ "author": "Callstack",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "bin": {
12
+ "agent-device": "bin/agent-device.mjs"
13
+ },
14
+ "scripts": {
15
+ "lint": "node --eval \"console.log('no lint')\"",
16
+ "build": "rslib build",
17
+ "build:node": "pnpm build && rm -f ~/.agent-device/daemon.json",
18
+ "build:swift": "swift build -c release --package-path ios-runner/AXSnapshot",
19
+ "build:axsnapshot": "pnpm build:swift && mkdir -p bin && cp -f ios-runner/AXSnapshot/.build/release/axsnapshot bin/axsnapshot && chmod +x bin/axsnapshot",
20
+ "build:clis": "pnpm build:node && pnpm build:axsnapshot",
21
+ "format": "prettier --write .",
22
+ "prepublishOnly": "pnpm build:node && pnpm build:axsnapshot",
23
+ "typecheck": "tsc -p tsconfig.json",
24
+ "test": "node --test",
25
+ "test:smoke": "node --test test/smoke/*.test.ts",
26
+ "test:integration": "node --test test/integration/*.test.ts"
27
+ },
28
+ "files": [
29
+ "bin",
30
+ "dist",
31
+ "bin/axsnapshot",
32
+ "ios-runner",
33
+ "!ios-runner/**/.build",
34
+ "!ios-runner/**/.swiftpm",
35
+ "!ios-runner/**/xcuserdata",
36
+ "!ios-runner/**/*.xcuserstate",
37
+ "src",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "keywords": [
42
+ "agent",
43
+ "device",
44
+ "cli",
45
+ "adb",
46
+ "simctl",
47
+ "devicectl",
48
+ "ios",
49
+ "android"
50
+ ],
51
+ "prettier": {
52
+ "singleQuote": true,
53
+ "semi": true,
54
+ "trailingComma": "all",
55
+ "printWidth": 100
56
+ },
57
+ "dependencies": {
58
+ "@clack/prompts": "^1.0.0"
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "^22.0.0",
62
+ "@rslib/core": "0.19.4",
63
+ "prettier": "^3.3.3",
64
+ "typescript": "^5.9.3"
65
+ }
66
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { runCli } from './cli.ts';
2
+
3
+ runCli(process.argv.slice(2));
package/src/cli.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { parseArgs, usage } from './utils/args.ts';
2
+ import { asAppError, AppError } from './utils/errors.ts';
3
+ import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { sendToDaemon } from './daemon-client.ts';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ export async function runCli(argv: string[]): Promise<void> {
11
+ const parsed = parseArgs(argv);
12
+
13
+ if (parsed.flags.help || !parsed.command) {
14
+ process.stdout.write(`${usage()}\n`);
15
+ process.exit(parsed.flags.help ? 0 : 1);
16
+ }
17
+
18
+ const { command, positionals, flags } = parsed;
19
+ const sessionName = flags.session ?? process.env.AGENT_DEVICE_SESSION ?? 'default';
20
+ const logTailStopper = flags.verbose && !flags.json ? startDaemonLogTail() : null;
21
+ try {
22
+ if (command === 'session') {
23
+ const sub = positionals[0] ?? 'list';
24
+ if (sub !== 'list') {
25
+ throw new AppError('INVALID_ARGS', 'session only supports list');
26
+ }
27
+ const response = await sendToDaemon({
28
+ session: sessionName,
29
+ command: 'session_list',
30
+ positionals: [],
31
+ flags: {},
32
+ });
33
+ if (!response.ok) throw new AppError(response.error.code as any, response.error.message);
34
+ if (flags.json) printJson({ success: true, data: response.data ?? {} });
35
+ else process.stdout.write(`${JSON.stringify(response.data ?? {}, null, 2)}\n`);
36
+ if (logTailStopper) logTailStopper();
37
+ return;
38
+ }
39
+
40
+ const response = await sendToDaemon({
41
+ session: sessionName,
42
+ command: command!,
43
+ positionals,
44
+ flags,
45
+ });
46
+
47
+ if (response.ok) {
48
+ if (flags.json) {
49
+ printJson({ success: true, data: response.data ?? {} });
50
+ if (logTailStopper) logTailStopper();
51
+ return;
52
+ }
53
+ if (command === 'snapshot') {
54
+ process.stdout.write(
55
+ formatSnapshotText((response.data ?? {}) as Record<string, unknown>, {
56
+ raw: flags.snapshotRaw,
57
+ }),
58
+ );
59
+ if (logTailStopper) logTailStopper();
60
+ return;
61
+ }
62
+ if (command === 'get') {
63
+ const sub = positionals[0];
64
+ if (sub === 'text') {
65
+ const text = (response.data as any)?.text ?? '';
66
+ process.stdout.write(`${text}\n`);
67
+ if (logTailStopper) logTailStopper();
68
+ return;
69
+ }
70
+ if (sub === 'attrs') {
71
+ const node = (response.data as any)?.node ?? {};
72
+ process.stdout.write(`${JSON.stringify(node, null, 2)}\n`);
73
+ if (logTailStopper) logTailStopper();
74
+ return;
75
+ }
76
+ }
77
+ if (command === 'click') {
78
+ const ref = (response.data as any)?.ref ?? '';
79
+ const x = (response.data as any)?.x;
80
+ const y = (response.data as any)?.y;
81
+ if (ref && typeof x === 'number' && typeof y === 'number') {
82
+ process.stdout.write(`Clicked @${ref} (${x}, ${y})\n`);
83
+ }
84
+ if (logTailStopper) logTailStopper();
85
+ return;
86
+ }
87
+ if (logTailStopper) logTailStopper();
88
+ return;
89
+ }
90
+
91
+ throw new AppError(response.error.code as any, response.error.message, response.error.details);
92
+ } catch (err) {
93
+ const appErr = asAppError(err);
94
+ if (flags.json) {
95
+ printJson({
96
+ success: false,
97
+ error: { code: appErr.code, message: appErr.message, details: appErr.details },
98
+ });
99
+ } else {
100
+ printHumanError(appErr);
101
+ if (flags.verbose) {
102
+ try {
103
+ const fs = await import('node:fs');
104
+ const os = await import('node:os');
105
+ const path = await import('node:path');
106
+ const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log');
107
+ if (fs.existsSync(logPath)) {
108
+ const content = fs.readFileSync(logPath, 'utf8');
109
+ const lines = content.split('\n');
110
+ const tail = lines.slice(Math.max(0, lines.length - 200)).join('\n');
111
+ if (tail.trim().length > 0) {
112
+ process.stderr.write(`\n[daemon log]\n${tail}\n`);
113
+ }
114
+ }
115
+ } catch {
116
+ // ignore
117
+ }
118
+ }
119
+ }
120
+ if (logTailStopper) logTailStopper();
121
+ process.exit(1);
122
+ }
123
+ }
124
+
125
+ const isDirectRun = pathToFileURL(process.argv[1] ?? '').href === import.meta.url;
126
+ if (isDirectRun) {
127
+ runCli(process.argv.slice(2)).catch((err) => {
128
+ const appErr = asAppError(err);
129
+ printHumanError(appErr);
130
+ process.exit(1);
131
+ });
132
+ }
133
+
134
+ function startDaemonLogTail(): (() => void) | null {
135
+ try {
136
+ const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log');
137
+ let offset = 0;
138
+ let stopped = false;
139
+ const interval = setInterval(() => {
140
+ if (stopped) return;
141
+ if (!fs.existsSync(logPath)) return;
142
+ const stats = fs.statSync(logPath);
143
+ if (stats.size <= offset) return;
144
+ const fd = fs.openSync(logPath, 'r');
145
+ const buffer = Buffer.alloc(stats.size - offset);
146
+ fs.readSync(fd, buffer, 0, buffer.length, offset);
147
+ fs.closeSync(fd);
148
+ offset = stats.size;
149
+ if (buffer.length > 0) {
150
+ process.stdout.write(buffer.toString('utf8'));
151
+ }
152
+ }, 200);
153
+ return () => {
154
+ stopped = true;
155
+ clearInterval(interval);
156
+ };
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
@@ -0,0 +1,259 @@
1
+ import { AppError } from '../utils/errors.ts';
2
+ import { selectDevice, type DeviceInfo } from '../utils/device.ts';
3
+ import { listAndroidDevices } from '../platforms/android/devices.ts';
4
+ import { ensureAdb, snapshotAndroid } from '../platforms/android/index.ts';
5
+ import { listIosDevices } from '../platforms/ios/devices.ts';
6
+ import { getInteractor } from '../utils/interactors.ts';
7
+ import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
8
+ import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
9
+ import type { RawSnapshotNode } from '../utils/snapshot.ts';
10
+
11
+ export type CommandFlags = {
12
+ platform?: 'ios' | 'android';
13
+ device?: string;
14
+ udid?: string;
15
+ serial?: string;
16
+ out?: string;
17
+ verbose?: boolean;
18
+ snapshotInteractiveOnly?: boolean;
19
+ snapshotCompact?: boolean;
20
+ snapshotDepth?: number;
21
+ snapshotScope?: string;
22
+ snapshotRaw?: boolean;
23
+ snapshotBackend?: 'ax' | 'xctest';
24
+ noRecord?: boolean;
25
+ recordJson?: boolean;
26
+ };
27
+
28
+ export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceInfo> {
29
+ const selector = {
30
+ platform: flags.platform,
31
+ deviceName: flags.device,
32
+ udid: flags.udid,
33
+ serial: flags.serial,
34
+ };
35
+
36
+ if (selector.platform === 'android') {
37
+ await ensureAdb();
38
+ const devices = await listAndroidDevices();
39
+ return await selectDevice(devices, selector);
40
+ }
41
+
42
+ if (selector.platform === 'ios') {
43
+ const devices = await listIosDevices();
44
+ return await selectDevice(devices, selector);
45
+ }
46
+
47
+ const devices: DeviceInfo[] = [];
48
+ try {
49
+ devices.push(...(await listAndroidDevices()));
50
+ } catch {
51
+ // ignore
52
+ }
53
+ try {
54
+ devices.push(...(await listIosDevices()));
55
+ } catch {
56
+ // ignore
57
+ }
58
+ return await selectDevice(devices, selector);
59
+ }
60
+
61
+ export async function dispatchCommand(
62
+ device: DeviceInfo,
63
+ command: string,
64
+ positionals: string[],
65
+ outPath?: string,
66
+ context?: {
67
+ appBundleId?: string;
68
+ verbose?: boolean;
69
+ logPath?: string;
70
+ snapshotInteractiveOnly?: boolean;
71
+ snapshotCompact?: boolean;
72
+ snapshotDepth?: number;
73
+ snapshotScope?: string;
74
+ snapshotRaw?: boolean;
75
+ },
76
+ ): Promise<Record<string, unknown> | void> {
77
+ const interactor = getInteractor(device);
78
+ switch (command) {
79
+ case 'open': {
80
+ const app = positionals[0];
81
+ if (!app) throw new AppError('INVALID_ARGS', 'open requires an app name or bundle/package id');
82
+ await interactor.open(app);
83
+ return { app };
84
+ }
85
+ case 'close': {
86
+ const app = positionals[0];
87
+ if (!app) {
88
+ return { closed: 'session' };
89
+ }
90
+ await interactor.close(app);
91
+ return { app };
92
+ }
93
+ case 'press': {
94
+ const [x, y] = positionals.map(Number);
95
+ if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'press requires x y');
96
+ if (device.platform === 'ios' && device.kind === 'simulator') {
97
+ await runIosRunnerCommand(
98
+ device,
99
+ { command: 'tap', x, y, appBundleId: context?.appBundleId },
100
+ { verbose: context?.verbose, logPath: context?.logPath },
101
+ );
102
+ } else {
103
+ await interactor.tap(x, y);
104
+ }
105
+ return { x, y };
106
+ }
107
+ case 'long-press': {
108
+ const x = Number(positionals[0]);
109
+ const y = Number(positionals[1]);
110
+ const durationMs = positionals[2] ? Number(positionals[2]) : undefined;
111
+ if (Number.isNaN(x) || Number.isNaN(y)) {
112
+ throw new AppError('INVALID_ARGS', 'long-press requires x y [durationMs]');
113
+ }
114
+ await interactor.longPress(x, y, durationMs);
115
+ return { x, y, durationMs };
116
+ }
117
+ case 'focus': {
118
+ const [x, y] = positionals.map(Number);
119
+ if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'focus requires x y');
120
+ if (device.platform === 'ios' && device.kind === 'simulator') {
121
+ await runIosRunnerCommand(
122
+ device,
123
+ { command: 'tap', x, y, appBundleId: context?.appBundleId },
124
+ { verbose: context?.verbose, logPath: context?.logPath },
125
+ );
126
+ } else {
127
+ await interactor.focus(x, y);
128
+ }
129
+ return { x, y };
130
+ }
131
+ case 'type': {
132
+ const text = positionals.join(' ');
133
+ if (!text) throw new AppError('INVALID_ARGS', 'type requires text');
134
+ if (device.platform === 'ios' && device.kind === 'simulator') {
135
+ await runIosRunnerCommand(
136
+ device,
137
+ { command: 'type', text, appBundleId: context?.appBundleId },
138
+ { verbose: context?.verbose, logPath: context?.logPath },
139
+ );
140
+ } else {
141
+ await interactor.type(text);
142
+ }
143
+ return { text };
144
+ }
145
+ case 'fill': {
146
+ const x = Number(positionals[0]);
147
+ const y = Number(positionals[1]);
148
+ const text = positionals.slice(2).join(' ');
149
+ if (Number.isNaN(x) || Number.isNaN(y) || !text) {
150
+ throw new AppError('INVALID_ARGS', 'fill requires x y text');
151
+ }
152
+ if (device.platform === 'ios' && device.kind === 'simulator') {
153
+ await runIosRunnerCommand(
154
+ device,
155
+ { command: 'tap', x, y, appBundleId: context?.appBundleId },
156
+ { verbose: context?.verbose, logPath: context?.logPath },
157
+ );
158
+ await runIosRunnerCommand(
159
+ device,
160
+ { command: 'type', text, appBundleId: context?.appBundleId },
161
+ { verbose: context?.verbose, logPath: context?.logPath },
162
+ );
163
+ } else {
164
+ await interactor.fill(x, y, text);
165
+ }
166
+ return { x, y, text };
167
+ }
168
+ case 'scroll': {
169
+ const direction = positionals[0];
170
+ const amount = positionals[1] ? Number(positionals[1]) : undefined;
171
+ if (!direction) throw new AppError('INVALID_ARGS', 'scroll requires direction');
172
+ if (device.platform === 'ios' && device.kind === 'simulator') {
173
+ if (!['up', 'down', 'left', 'right'].includes(direction)) {
174
+ throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`);
175
+ }
176
+ const inverted = invertScrollDirection(direction as 'up' | 'down' | 'left' | 'right');
177
+ await runIosRunnerCommand(
178
+ device,
179
+ { command: 'swipe', direction: inverted, appBundleId: context?.appBundleId },
180
+ { verbose: context?.verbose, logPath: context?.logPath },
181
+ );
182
+ } else {
183
+ await interactor.scroll(direction, amount);
184
+ }
185
+ return { direction, amount };
186
+ }
187
+ case 'scrollintoview': {
188
+ const text = positionals.join(' ');
189
+ if (!text) throw new AppError('INVALID_ARGS', 'scrollintoview requires text');
190
+ await interactor.scrollIntoView(text);
191
+ return { text };
192
+ }
193
+ case 'screenshot': {
194
+ const path = outPath ?? `./screenshot-${Date.now()}.png`;
195
+ await interactor.screenshot(path);
196
+ return { path };
197
+ }
198
+ case 'snapshot': {
199
+ const backend = context?.snapshotBackend ?? 'ax';
200
+ if (device.platform === 'ios') {
201
+ if (device.kind !== 'simulator') {
202
+ throw new AppError(
203
+ 'UNSUPPORTED_OPERATION',
204
+ 'snapshot is only supported on iOS simulators in v1',
205
+ );
206
+ }
207
+ if (backend === 'ax') {
208
+ try {
209
+ const ax = await snapshotAx(device);
210
+ return { nodes: ax.nodes ?? [], truncated: false, backend: 'ax', rootRect: ax.rootRect };
211
+ } catch (err) {
212
+ if (context?.snapshotBackend === 'ax') {
213
+ throw err;
214
+ }
215
+ }
216
+ }
217
+ const result = (await runIosRunnerCommand(
218
+ device,
219
+ {
220
+ command: 'snapshot',
221
+ appBundleId: context?.appBundleId,
222
+ interactiveOnly: context?.snapshotInteractiveOnly,
223
+ compact: context?.snapshotCompact,
224
+ depth: context?.snapshotDepth,
225
+ scope: context?.snapshotScope,
226
+ raw: context?.snapshotRaw,
227
+ },
228
+ { verbose: context?.verbose, logPath: context?.logPath },
229
+ )) as { nodes?: RawSnapshotNode[]; truncated?: boolean };
230
+ return { nodes: result.nodes ?? [], truncated: result.truncated ?? false, backend: 'xctest' };
231
+ }
232
+ const androidResult = await snapshotAndroid(device, {
233
+ interactiveOnly: context?.snapshotInteractiveOnly,
234
+ compact: context?.snapshotCompact,
235
+ depth: context?.snapshotDepth,
236
+ scope: context?.snapshotScope,
237
+ raw: context?.snapshotRaw,
238
+ });
239
+ return { nodes: androidResult.nodes ?? [], truncated: androidResult.truncated ?? false, backend: 'android' };
240
+ }
241
+ default:
242
+ throw new AppError('INVALID_ARGS', `Unknown command: ${command}`);
243
+ }
244
+ }
245
+
246
+ function invertScrollDirection(direction: 'up' | 'down' | 'left' | 'right'): 'up' | 'down' | 'left' | 'right' {
247
+ switch (direction) {
248
+ case 'up':
249
+ return 'down';
250
+ case 'down':
251
+ return 'up';
252
+ case 'left':
253
+ return 'right';
254
+ case 'right':
255
+ return 'left';
256
+ }
257
+ }
258
+
259
+ // Runner-only input on iOS simulators (simctl io input is not supported).
@@ -0,0 +1,166 @@
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 { fileURLToPath } from 'node:url';
6
+ import { AppError } from './utils/errors.ts';
7
+ import type { CommandFlags } from './core/dispatch.ts';
8
+ import { runCmdDetached } from './utils/exec.ts';
9
+
10
+ export type DaemonRequest = {
11
+ token: string;
12
+ session: string;
13
+ command: string;
14
+ positionals: string[];
15
+ flags?: CommandFlags;
16
+ };
17
+
18
+ export type DaemonResponse =
19
+ | { ok: true; data?: Record<string, unknown> }
20
+ | { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
21
+
22
+ type DaemonInfo = { port: number; token: string; pid: number; version?: string };
23
+
24
+ const baseDir = path.join(os.homedir(), '.agent-device');
25
+ const infoPath = path.join(baseDir, 'daemon.json');
26
+ const REQUEST_TIMEOUT_MS = resolveRequestTimeoutMs();
27
+ const DAEMON_STARTUP_TIMEOUT_MS = 5000;
28
+
29
+ export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
30
+ const info = await ensureDaemon();
31
+ const request = { ...req, token: info.token };
32
+ return await sendRequest(info, request);
33
+ }
34
+
35
+ async function ensureDaemon(): Promise<DaemonInfo> {
36
+ const existing = readDaemonInfo();
37
+ const localVersion = readVersion();
38
+ if (existing && existing.version === localVersion && (await canConnect(existing))) return existing;
39
+ if (existing && (existing.version !== localVersion || !(await canConnect(existing)))) {
40
+ removeDaemonInfo();
41
+ }
42
+
43
+ await startDaemon();
44
+
45
+ const start = Date.now();
46
+ while (Date.now() - start < DAEMON_STARTUP_TIMEOUT_MS) {
47
+ const info = readDaemonInfo();
48
+ if (info && (await canConnect(info))) return info;
49
+ await new Promise((resolve) => setTimeout(resolve, 100));
50
+ }
51
+
52
+ throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
53
+ infoPath,
54
+ hint: 'Run pnpm build, or delete ~/.agent-device/daemon.json if stale.',
55
+ });
56
+ }
57
+
58
+ function readDaemonInfo(): DaemonInfo | null {
59
+ if (!fs.existsSync(infoPath)) return null;
60
+ try {
61
+ const data = JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo;
62
+ if (!data.port || !data.token) return null;
63
+ return data;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function removeDaemonInfo(): void {
70
+ if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
71
+ }
72
+
73
+ async function canConnect(info: DaemonInfo): Promise<boolean> {
74
+ return new Promise((resolve) => {
75
+ const socket = net.createConnection({ host: '127.0.0.1', port: info.port }, () => {
76
+ socket.destroy();
77
+ resolve(true);
78
+ });
79
+ socket.on('error', () => {
80
+ resolve(false);
81
+ });
82
+ });
83
+ }
84
+
85
+ async function startDaemon(): Promise<void> {
86
+ const root = findProjectRoot();
87
+ const distPath = path.join(root, 'dist', 'src', 'daemon.js');
88
+ const srcPath = path.join(root, 'src', 'daemon.ts');
89
+
90
+ const useDist = fs.existsSync(distPath);
91
+ if (!useDist && !fs.existsSync(srcPath)) {
92
+ throw new AppError('COMMAND_FAILED', 'Daemon entry not found', { distPath, srcPath });
93
+ }
94
+ const args = useDist ? [distPath] : ['--experimental-strip-types', srcPath];
95
+
96
+ runCmdDetached(process.execPath, args);
97
+ }
98
+
99
+ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<DaemonResponse> {
100
+ return new Promise((resolve, reject) => {
101
+ const socket = net.createConnection({ host: '127.0.0.1', port: info.port }, () => {
102
+ socket.write(`${JSON.stringify(req)}\n`);
103
+ });
104
+ const timeout = setTimeout(() => {
105
+ socket.destroy();
106
+ reject(
107
+ new AppError('COMMAND_FAILED', 'Daemon request timed out', { timeoutMs: REQUEST_TIMEOUT_MS }),
108
+ );
109
+ }, REQUEST_TIMEOUT_MS);
110
+
111
+ let buffer = '';
112
+ socket.setEncoding('utf8');
113
+ socket.on('data', (chunk) => {
114
+ buffer += chunk;
115
+ const idx = buffer.indexOf('\n');
116
+ if (idx === -1) return;
117
+ const line = buffer.slice(0, idx).trim();
118
+ if (!line) return;
119
+ try {
120
+ const response = JSON.parse(line) as DaemonResponse;
121
+ socket.end();
122
+ clearTimeout(timeout);
123
+ resolve(response);
124
+ } catch (err) {
125
+ clearTimeout(timeout);
126
+ reject(err);
127
+ }
128
+ });
129
+
130
+ socket.on('error', (err) => {
131
+ clearTimeout(timeout);
132
+ reject(err);
133
+ });
134
+ });
135
+ }
136
+
137
+ function readVersion(): string {
138
+ try {
139
+ const root = findProjectRoot();
140
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
141
+ version?: string;
142
+ };
143
+ return pkg.version ?? '0.0.0';
144
+ } catch {
145
+ return '0.0.0';
146
+ }
147
+ }
148
+
149
+ function resolveRequestTimeoutMs(): number {
150
+ const raw = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS;
151
+ if (!raw) return 60000;
152
+ const parsed = Number(raw);
153
+ if (!Number.isFinite(parsed)) return 60000;
154
+ return Math.max(1000, Math.floor(parsed));
155
+ }
156
+
157
+ function findProjectRoot(): string {
158
+ const start = path.dirname(fileURLToPath(import.meta.url));
159
+ let current = start;
160
+ for (let i = 0; i < 6; i += 1) {
161
+ const pkgPath = path.join(current, 'package.json');
162
+ if (fs.existsSync(pkgPath)) return current;
163
+ current = path.dirname(current);
164
+ }
165
+ return start;
166
+ }