mobile-automator 0.23.8

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 (61) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +167 -0
  3. package/bin/mauto-session-daemon.js +101 -0
  4. package/bin/mauto.js +13 -0
  5. package/package.json +49 -0
  6. package/src/assertion/evaluator.js +285 -0
  7. package/src/cli.js +1722 -0
  8. package/src/config/coerce.js +97 -0
  9. package/src/config/manager.js +75 -0
  10. package/src/config/schema.js +124 -0
  11. package/src/device/action-catalog.js +99 -0
  12. package/src/device/bridge.js +139 -0
  13. package/src/device/connection.js +65 -0
  14. package/src/device/device-model.js +59 -0
  15. package/src/device/device-resolver.js +34 -0
  16. package/src/device/element-model.js +82 -0
  17. package/src/device/mobile-mcp-client.js +163 -0
  18. package/src/device/resolve-connection.js +156 -0
  19. package/src/device/selection.js +80 -0
  20. package/src/device/semantic-press/index.js +38 -0
  21. package/src/device/semantic-press/labels.js +39 -0
  22. package/src/device/semantic-press/resolver.js +100 -0
  23. package/src/device/session-client.js +173 -0
  24. package/src/device/session-daemon.js +526 -0
  25. package/src/device/session-paths.js +80 -0
  26. package/src/device/session-protocol.js +60 -0
  27. package/src/device/session-spawn.js +67 -0
  28. package/src/device/tool-args.js +14 -0
  29. package/src/guide/content/execute.agnostic.md +207 -0
  30. package/src/guide/content/execute.aware.md +281 -0
  31. package/src/guide/content/execute.invariants.md +4 -0
  32. package/src/guide/content/generate.agnostic.md +236 -0
  33. package/src/guide/content/generate.aware.md +327 -0
  34. package/src/guide/content/generate.invariants.md +5 -0
  35. package/src/guide/content/setup.agnostic.md +63 -0
  36. package/src/guide/content/setup.aware.md +91 -0
  37. package/src/guide/content/setup.invariants.md +3 -0
  38. package/src/guide/emitter.js +137 -0
  39. package/src/guide/placeholders.js +132 -0
  40. package/src/init/adapters.js +162 -0
  41. package/src/init/skill-meta.js +31 -0
  42. package/src/init/skill-renderer.js +60 -0
  43. package/src/mcp/prompts.js +55 -0
  44. package/src/mcp/server.js +55 -0
  45. package/src/memory/entries.js +61 -0
  46. package/src/memory/history.js +86 -0
  47. package/src/memory/lock.js +5 -0
  48. package/src/memory/paths.js +47 -0
  49. package/src/memory/store.js +230 -0
  50. package/src/output/envelope.js +106 -0
  51. package/src/result/capability-catalog.js +144 -0
  52. package/src/result/flags.js +52 -0
  53. package/src/result/store.js +273 -0
  54. package/src/scenario/validator.js +30 -0
  55. package/src/schemas/config_schema.json +68 -0
  56. package/src/schemas/format-error.js +19 -0
  57. package/src/schemas/result_schema.json +211 -0
  58. package/src/schemas/scenario_schema.json +587 -0
  59. package/src/setup/scaffold.js +53 -0
  60. package/src/util/atomic.js +41 -0
  61. package/src/util/lock.js +85 -0
@@ -0,0 +1,526 @@
1
+ 'use strict';
2
+
3
+ // Long-lived device session daemon.
4
+ //
5
+ // Builds ONE mobile-mcp connection (via the injected `createCall`) and serves
6
+ // every connecting one-shot verb over a per-workspace Unix domain socket. The
7
+ // single connection is the whole point of the feature: a 40-step scenario pays
8
+ // the spawn+handshake tax exactly once.
9
+ //
10
+ // Lifecycle:
11
+ // - clean any stale socket/pidfile before binding,
12
+ // - listen on the socket, write handle + pidfile once listening,
13
+ // - route { type:'call', tool, args } frames to the single `call`,
14
+ // - reset an idle timer on every request; reap the daemon when it expires,
15
+ // - shut down cleanly on a { type:'shutdown' } control frame and on
16
+ // SIGTERM/SIGINT, removing the socket/pidfile/handle.
17
+ //
18
+ // Everything that touches a real device is injected, so the full lifecycle is
19
+ // unit-testable with a fake createCall and a mkdtemp project root.
20
+
21
+ const childProcess = require('child_process');
22
+ const fs = require('fs');
23
+ const net = require('net');
24
+
25
+ const paths = require('./session-paths');
26
+ const { FrameParser } = require('./session-protocol');
27
+
28
+ const DEFAULT_IDLE_MS = 5 * 60 * 1000;
29
+
30
+ // Per-call timeout for the shared mobile-mcp connection. Deliberately BELOW the
31
+ // client's DEFAULT_TIMEOUT_MS (30000) so the daemon's timeout error always wins
32
+ // the race and reaches the client as an honest {kind:'timeout'} reply instead of
33
+ // being dropped by the client's own 30s timeout (which would look like a
34
+ // false-failure and prompt a retry → double-execution on the device). The
35
+ // structural invariant CLIENT_TIMEOUT > DAEMON_CALL_TIMEOUT is pinned by
36
+ // tests/unit/device/timeout-invariant.test.js.
37
+ const DAEMON_CALL_TIMEOUT_MS = 25000;
38
+
39
+ // Resolves after `ms` without keeping the process alive. Injectable so tests
40
+ // can drive the timeout path without waiting 25s.
41
+ function defaultScheduleTimeout(ms) {
42
+ return new Promise((resolve) => {
43
+ const t = setTimeout(resolve, ms);
44
+ if (typeof t.unref === 'function') t.unref();
45
+ });
46
+ }
47
+
48
+ function defaultCreateCall(opts) {
49
+ return require('./mobile-mcp-client').createCall(opts);
50
+ }
51
+
52
+ // Best-effort unlink — never throws.
53
+ function safeUnlink(p) {
54
+ try {
55
+ fs.unlinkSync(p);
56
+ } catch (_) {
57
+ /* not present */
58
+ }
59
+ }
60
+
61
+ // True when a pid is a live process we own. ESRCH => dead; EPERM => alive.
62
+ // Kept exported for back-compat; cleanStale routes through pidIdentity, which
63
+ // also checks the process's identity (not just liveness).
64
+ function pidAlive(pid) {
65
+ if (!pid || !Number.isInteger(pid)) return false;
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ } catch (err) {
70
+ return err.code === 'EPERM';
71
+ }
72
+ }
73
+
74
+ // Classify a pid by liveness AND identity. Liveness alone is not enough: a
75
+ // recycled pid (an unrelated process that inherited the dead daemon's pid) is
76
+ // "alive" but is NOT our daemon, so a stale lock/pidfile pointing at it must be
77
+ // cleaned rather than respected. Returns one of:
78
+ // 'dead' - no such process (ESRCH or any non-EPERM kill error)
79
+ // 'ours' - alive AND its command line contains `mauto-session-daemon`
80
+ // 'other' - alive AND its command line does NOT (recycled pid)
81
+ // 'unknown' - alive but `ps` itself failed (e.g. no `ps` on the host) —
82
+ // treated as 'ours' by cleanStale: never clean when unsure.
83
+ // `execFile` is injectable for tests; a failure there must never crash the
84
+ // daemon, hence the 'unknown' fallback.
85
+ function pidIdentity(pid, { execFile = childProcess.execFileSync } = {}) {
86
+ if (!pid || !Number.isInteger(pid)) return 'dead';
87
+ // Our own pid is the in-process daemon (unit tests run startDaemon in the
88
+ // test process, so the lock/pidfile carry the test runner's pid). Never clean
89
+ // files that point at ourselves. In production cleanStale runs before the
90
+ // daemon writes its own pid/lock, so this branch only fires in-process.
91
+ if (pid === process.pid) return 'ours';
92
+ let alive;
93
+ try {
94
+ process.kill(pid, 0);
95
+ alive = true;
96
+ } catch (err) {
97
+ if (err.code === 'EPERM') alive = true;
98
+ else return 'dead';
99
+ }
100
+ if (!alive) return 'dead';
101
+ try {
102
+ const out = execFile('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
103
+ return /mauto-session-daemon/.test(String(out || '')) ? 'ours' : 'other';
104
+ } catch (_) {
105
+ return 'unknown';
106
+ }
107
+ }
108
+
109
+ // Read a pid out of a one-line pid/lock file, or null when absent/garbage.
110
+ function readPidFile(file) {
111
+ try {
112
+ const pid = parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
113
+ return Number.isInteger(pid) ? pid : null;
114
+ } catch (_) {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ // Remove stale socket/pidfile/handle/lock left by a crashed daemon so a fresh
120
+ // daemon can bind. Identity is decided from the LOCK file first (acquired before
121
+ // the connection is built, so it covers a daemon still mid-startup) then the
122
+ // pidfile (written once listening). If either points at a LIVE process that is
123
+ // OURS (or unclassifiable — 'unknown') we leave everything alone and signal the
124
+ // caller — the lock's O_EXCL or the listen() EADDRINUSE then surfaces the
125
+ // conflict rather than hijacking a healthy daemon. A pid that is alive but NOT
126
+ // ours ('other') is a RECYCLED pid: the daemon that wrote it is long dead and an
127
+ // unrelated process inherited the number, so the stale files are cleaned instead
128
+ // of wedging every spawn for the lifetime of that unrelated process.
129
+ function cleanStale(projectRoot, { execFile } = {}) {
130
+ const pidFile = paths.pidFilePath(projectRoot);
131
+ const lockFile = paths.lockPath(projectRoot);
132
+ let livePid = null;
133
+ for (const f of [lockFile, pidFile]) {
134
+ const pid = readPidFile(f);
135
+ if (!pid) continue;
136
+ const id = pidIdentity(pid, { execFile });
137
+ if (id === 'dead') continue; // keep scanning; ultimately clean
138
+ if (id === 'ours' || id === 'unknown') {
139
+ livePid = pid;
140
+ break;
141
+ }
142
+ // id === 'other' → recycled pid → fall through to the safeUnlinks.
143
+ }
144
+ if (livePid) return { cleaned: false, livePid };
145
+
146
+ safeUnlink(paths.socketPath(projectRoot));
147
+ safeUnlink(pidFile);
148
+ safeUnlink(paths.handlePath(projectRoot));
149
+ safeUnlink(lockFile);
150
+ return { cleaned: true, livePid: null };
151
+ }
152
+
153
+ async function startDaemon({
154
+ projectRoot,
155
+ device = null,
156
+ idleMs = DEFAULT_IDLE_MS,
157
+ createCall = defaultCreateCall,
158
+ onUndeliverable = null,
159
+ scheduleTimeout = defaultScheduleTimeout,
160
+ execFile = childProcess.execFileSync,
161
+ } = {}) {
162
+ if (!projectRoot) throw new TypeError('startDaemon requires projectRoot');
163
+
164
+ const dir = paths.sessionDir(projectRoot);
165
+ fs.mkdirSync(dir, { recursive: true });
166
+
167
+ // Reap a crashed daemon's leftovers before we bind.
168
+ cleanStale(projectRoot, { execFile });
169
+
170
+ // Acquire an EXCLUSIVE per-workspace lock BEFORE building the connection. Two
171
+ // `mauto` invocations that both saw isAlive=false would otherwise both spawn a
172
+ // daemon and both call createCall (spawning mobile-mcp + grabbing the device)
173
+ // before the loser hits EADDRINUSE. With the lock the loser fails here and
174
+ // exits before it ever spawns a child. O_EXCL ('wx') is the atomicity.
175
+ const lockFile = paths.lockPath(projectRoot);
176
+ let lockFd;
177
+ try {
178
+ lockFd = fs.openSync(lockFile, 'wx');
179
+ fs.writeSync(lockFd, String(process.pid) + '\n');
180
+ } catch (err) {
181
+ if (err && err.code === 'EEXIST') {
182
+ const e = new Error('device session daemon already starting for this workspace (lock held)');
183
+ e.code = 'ELOCKED';
184
+ throw e;
185
+ }
186
+ throw err;
187
+ }
188
+
189
+ let lockReleased = false;
190
+ function releaseLock() {
191
+ if (lockReleased) return;
192
+ lockReleased = true;
193
+ try {
194
+ if (lockFd !== undefined) fs.closeSync(lockFd);
195
+ } catch (_) {
196
+ /* ignore */
197
+ }
198
+ safeUnlink(lockFile);
199
+ }
200
+
201
+ // Build the single shared connection up front. If it rejects, never leak the
202
+ // lock (and the child it may have started is the connection's own to clean).
203
+ let call;
204
+ let close;
205
+ try {
206
+ ({ call, close } = await createCall({ device }));
207
+ } catch (err) {
208
+ releaseLock();
209
+ throw err;
210
+ }
211
+
212
+ let idleTimer = null;
213
+ let stopping = false;
214
+ let inFlight = 0; // device calls whose reply has not yet been sent
215
+ const sockets = new Set();
216
+ const undeliverable = []; // replies we could not deliver (peer gone / unflushable)
217
+ let onStop = null; // resolves stop()'s promise
218
+
219
+ const notifyUndeliverable =
220
+ typeof onUndeliverable === 'function'
221
+ ? onUndeliverable
222
+ : (info) => {
223
+ try {
224
+ process.stderr.write(
225
+ `mauto-session-daemon: undeliverable reply id=${info.id}: ${info.reason}\n`
226
+ );
227
+ } catch (_) {
228
+ /* stderr gone */
229
+ }
230
+ };
231
+
232
+ function recordUndeliverable(info) {
233
+ undeliverable.push(info);
234
+ notifyUndeliverable(info);
235
+ }
236
+
237
+ const server = net.createServer();
238
+
239
+ function clearIdle() {
240
+ if (idleTimer) {
241
+ clearTimeout(idleTimer);
242
+ idleTimer = null;
243
+ }
244
+ }
245
+
246
+ function armIdle() {
247
+ clearIdle();
248
+ if (stopping) return; // never re-arm once we have begun tearing down
249
+ if (idleMs > 0 && Number.isFinite(idleMs)) {
250
+ idleTimer = setTimeout(() => {
251
+ stop().catch(() => {});
252
+ }, idleMs);
253
+ if (typeof idleTimer.unref === 'function') idleTimer.unref();
254
+ }
255
+ }
256
+
257
+ async function stop() {
258
+ if (stopping) return;
259
+ stopping = true;
260
+ clearIdle();
261
+
262
+ // Drain-before-teardown: never destroy a socket or tear down the shared
263
+ // connection while a device call is still in flight. The action already hit
264
+ // the device — its reply must be allowed to go out first.
265
+ while (inFlight > 0) {
266
+ await new Promise((r) => setTimeout(r, 10));
267
+ }
268
+
269
+ for (const s of sockets) {
270
+ try {
271
+ s.destroy();
272
+ } catch (_) {
273
+ /* ignore */
274
+ }
275
+ }
276
+ sockets.clear();
277
+
278
+ await new Promise((resolve) => server.close(() => resolve()));
279
+
280
+ try {
281
+ await close();
282
+ } catch (_) {
283
+ /* best-effort */
284
+ }
285
+
286
+ safeUnlink(paths.socketPath(projectRoot));
287
+ safeUnlink(paths.pidFilePath(projectRoot));
288
+ safeUnlink(paths.handlePath(projectRoot));
289
+ releaseLock();
290
+
291
+ process.removeListener('SIGTERM', onSignal);
292
+ process.removeListener('SIGINT', onSignal);
293
+
294
+ if (onStop) onStop();
295
+ }
296
+
297
+ function onSignal() {
298
+ stop().catch(() => {});
299
+ }
300
+
301
+ server.on('connection', (socket) => {
302
+ sockets.add(socket);
303
+ const parser = new FrameParser();
304
+
305
+ socket.setEncoding('utf8');
306
+
307
+ // Honest reply: by the time this runs the device action has ALREADY
308
+ // happened, so a write that fails must never be silently swallowed.
309
+ // - a non-serializable result is converted to an explicit ok:false frame
310
+ // rather than dropped (a dropped reply hangs the client to a 30s
311
+ // false-failure → retry → double tap / re-granted permission);
312
+ // - socket backpressure (write() === false) is awaited via 'drain' so
313
+ // large payloads (screenshots, element trees) actually reach the client;
314
+ // - a destroyed peer / write error is RECORDED as undeliverable, not
315
+ // swallowed.
316
+ // Returns true when the frame was flushed, false when undeliverable.
317
+ const reply = async (obj) => {
318
+ const id = obj && obj.id != null ? obj.id : null;
319
+ let frame;
320
+ try {
321
+ frame = FrameParser.encode(obj);
322
+ } catch (encErr) {
323
+ // Result could not be serialized — surface it as an explicit failure
324
+ // frame instead of dropping the reply entirely.
325
+ frame = FrameParser.encode({
326
+ id,
327
+ ok: false,
328
+ error: { message: `result not serializable: ${encErr.message || encErr}` },
329
+ });
330
+ }
331
+
332
+ if (socket.destroyed || !socket.writable) {
333
+ recordUndeliverable({ id, reason: 'socket destroyed before reply' });
334
+ return false;
335
+ }
336
+
337
+ let flushed;
338
+ try {
339
+ flushed = socket.write(frame);
340
+ } catch (writeErr) {
341
+ recordUndeliverable({ id, reason: writeErr.message || String(writeErr) });
342
+ return false;
343
+ }
344
+
345
+ if (flushed === false) {
346
+ // Backpressure: wait for the kernel buffer to drain before treating the
347
+ // reply as delivered. Bail honestly if the peer dies first.
348
+ try {
349
+ await new Promise((resolve, reject) => {
350
+ const cleanup = () => {
351
+ socket.removeListener('drain', onDrain);
352
+ socket.removeListener('error', onErr);
353
+ socket.removeListener('close', onClose);
354
+ };
355
+ const onDrain = () => {
356
+ cleanup();
357
+ resolve();
358
+ };
359
+ const onErr = (e) => {
360
+ cleanup();
361
+ reject(e);
362
+ };
363
+ const onClose = () => {
364
+ cleanup();
365
+ reject(new Error('socket closed before drain'));
366
+ };
367
+ socket.once('drain', onDrain);
368
+ socket.once('error', onErr);
369
+ socket.once('close', onClose);
370
+ });
371
+ } catch (drainErr) {
372
+ recordUndeliverable({ id, reason: drainErr.message || String(drainErr) });
373
+ return false;
374
+ }
375
+ }
376
+ return true;
377
+ };
378
+
379
+ socket.on('data', async (chunk) => {
380
+ const frames = parser.push(chunk);
381
+ for (const f of frames) {
382
+ // Pause the idle clock while we handle a frame so it can never fire
383
+ // mid-call; it is re-armed only AFTER the reply has gone out.
384
+ clearIdle();
385
+
386
+ if (f.error) {
387
+ await reply({ id: null, ok: false, error: { message: `malformed frame: ${f.error.message}` } });
388
+ armIdle();
389
+ continue;
390
+ }
391
+ const req = f.value;
392
+
393
+ if (req.type === 'shutdown') {
394
+ await reply({ id: req.id, ok: true, result: { stopping: true } });
395
+ // Reply already flushed (awaited) — now tear down.
396
+ setImmediate(() => stop().catch(() => {}));
397
+ continue;
398
+ }
399
+ if (req.type === 'ping') {
400
+ await reply({ id: req.id, ok: true, result: { pong: true, device, in_flight: inFlight } });
401
+ armIdle();
402
+ continue;
403
+ }
404
+ if (req.type === 'call') {
405
+ // A call that arrives after stop() began must be rejected up front:
406
+ // it never started, so it must not block stop()'s drain (inFlight is
407
+ // only decremented for calls that actually incremented it).
408
+ if (stopping) {
409
+ await reply({
410
+ id: req.id,
411
+ ok: false,
412
+ error: { message: 'session daemon is shutting down', kind: 'device' },
413
+ });
414
+ armIdle();
415
+ continue;
416
+ }
417
+ inFlight += 1;
418
+ try {
419
+ // Bound the shared mobile-mcp call. The daemon's timeout (25s) is
420
+ // BELOW the client's (30s) so this error always reaches the client
421
+ // as an honest {kind:'timeout'} reply instead of being dropped by
422
+ // the client's own timeout — a dropped reply reads as a
423
+ // false-failure and prompts a retry → double-execution. The
424
+ // underlying call is NOT cancellable; it keeps running in the
425
+ // background, which is fine — the point is the agent gets a bounded,
426
+ // honest error. Swallow the loser's eventual settlement so it can't
427
+ // surface as an unhandled rejection after the race already settled.
428
+ const callPromise = call(req.tool, req.args || {});
429
+ callPromise.catch(() => {});
430
+ const result = await Promise.race([
431
+ callPromise,
432
+ scheduleTimeout(DAEMON_CALL_TIMEOUT_MS).then(() => {
433
+ const e = new Error(
434
+ `${req.tool}: the mobile-mcp call did not respond within ${DAEMON_CALL_TIMEOUT_MS}ms`
435
+ );
436
+ e.kind = 'timeout';
437
+ e.hint = 'the action may have partially executed on the device — verify state before retrying';
438
+ throw e;
439
+ }),
440
+ ]);
441
+ await reply({ id: req.id, ok: true, result });
442
+ } catch (err) {
443
+ if (err && err.kind === 'timeout') {
444
+ await reply({
445
+ id: req.id,
446
+ ok: false,
447
+ error: { message: err.message, kind: 'timeout', hint: err.hint },
448
+ });
449
+ } else {
450
+ // Non-timeout failure: no kind → client/deviceFail default to
451
+ // 'device'. Only the timeout path sets kind:'timeout'.
452
+ await reply({ id: req.id, ok: false, error: { message: err.message || String(err) } });
453
+ }
454
+ } finally {
455
+ inFlight -= 1;
456
+ armIdle(); // arm idle only after the reply has completed
457
+ }
458
+ continue;
459
+ }
460
+ await reply({ id: req.id, ok: false, error: { message: `unknown request type: ${req.type}` } });
461
+ armIdle();
462
+ }
463
+ });
464
+
465
+ socket.on('error', () => {
466
+ sockets.delete(socket);
467
+ });
468
+ socket.on('close', () => {
469
+ sockets.delete(socket);
470
+ });
471
+ });
472
+
473
+ const socketPath = paths.socketPath(projectRoot);
474
+
475
+ try {
476
+ await new Promise((resolve, reject) => {
477
+ server.once('error', reject);
478
+ server.listen(socketPath, () => {
479
+ server.removeListener('error', reject);
480
+ resolve();
481
+ });
482
+ });
483
+ } catch (err) {
484
+ // listen failed (e.g. EADDRINUSE from a double-spawn that beat us to the
485
+ // bind, or a recycled-pid wedge). The connection was already built — close
486
+ // it so we never leak the mobile-mcp child, and release the lock.
487
+ try {
488
+ await close();
489
+ } catch (_) {
490
+ /* best-effort */
491
+ }
492
+ releaseLock();
493
+ throw err;
494
+ }
495
+
496
+ // Persist handle + pidfile now that we are actually listening.
497
+ const handle = {
498
+ pid: process.pid,
499
+ device: device || null,
500
+ socket: socketPath,
501
+ started_at: new Date().toISOString(),
502
+ idle_ms: idleMs,
503
+ };
504
+ fs.writeFileSync(paths.handlePath(projectRoot), JSON.stringify(handle, null, 2) + '\n');
505
+ fs.writeFileSync(paths.pidFilePath(projectRoot), String(process.pid) + '\n');
506
+
507
+ process.on('SIGTERM', onSignal);
508
+ process.on('SIGINT', onSignal);
509
+
510
+ armIdle();
511
+
512
+ return {
513
+ socketPath,
514
+ device: device || null,
515
+ stop,
516
+ // Replies that could not be delivered (peer gone / unflushable). Surfaced so
517
+ // callers/tests can observe transport failures instead of them being eaten.
518
+ undeliverable,
519
+ // Resolves when the daemon has fully stopped (idle reap / signal / shutdown).
520
+ whenStopped: new Promise((resolve) => {
521
+ onStop = resolve;
522
+ }),
523
+ };
524
+ }
525
+
526
+ module.exports = { startDaemon, cleanStale, pidAlive, pidIdentity, DAEMON_CALL_TIMEOUT_MS, DEFAULT_IDLE_MS };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ // Pure path helpers for the per-workspace device session daemon.
4
+ //
5
+ // Everything lives under <projectRoot>/mobile-automator/.session/:
6
+ // mauto.sock — Unix domain socket the daemon listens on
7
+ // daemon.pid — pidfile written once the daemon is listening
8
+ // session.json — handle describing the live session (device pin, socket, pid)
9
+ //
10
+ // These are intentionally side-effect-free so they can be unit-tested without
11
+ // touching the filesystem.
12
+
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const crypto = require('crypto');
16
+
17
+ const SESSION_DIRNAME = '.session';
18
+ const SOCKET_NAME = 'mauto.sock';
19
+ const PID_NAME = 'daemon.pid';
20
+ const HANDLE_NAME = 'session.json';
21
+ const LOCK_NAME = 'daemon.lock';
22
+
23
+ // Unix domain socket paths have a hard length limit (~104 bytes on macOS,
24
+ // ~108 on Linux). Deep project roots blow past it, so when the in-workspace
25
+ // path would be too long we fall back to a short, deterministic socket path in
26
+ // the OS temp dir keyed by a hash of the project root. pidfile + handle always
27
+ // stay in the workspace (per-workspace ownership), and the handle records the
28
+ // actual socket path it bound.
29
+ const SOCKET_PATH_LIMIT = 100;
30
+
31
+ function sessionDir(projectRoot) {
32
+ return path.join(projectRoot, 'mobile-automator', SESSION_DIRNAME);
33
+ }
34
+
35
+ // The canonical in-workspace socket path. Used when short enough.
36
+ function workspaceSocketPath(projectRoot) {
37
+ return path.join(sessionDir(projectRoot), SOCKET_NAME);
38
+ }
39
+
40
+ // The path the daemon binds and the client connects to. Deterministic from the
41
+ // project root so both sides agree without reading the handle.
42
+ function socketPath(projectRoot) {
43
+ const inWorkspace = workspaceSocketPath(projectRoot);
44
+ if (Buffer.byteLength(inWorkspace) <= SOCKET_PATH_LIMIT) return inWorkspace;
45
+ const hash = crypto
46
+ .createHash('sha1')
47
+ .update(path.resolve(projectRoot))
48
+ .digest('hex')
49
+ .slice(0, 16);
50
+ return path.join(os.tmpdir(), `mauto-${hash}.sock`);
51
+ }
52
+
53
+ function pidFilePath(projectRoot) {
54
+ return path.join(sessionDir(projectRoot), PID_NAME);
55
+ }
56
+
57
+ function handlePath(projectRoot) {
58
+ return path.join(sessionDir(projectRoot), HANDLE_NAME);
59
+ }
60
+
61
+ // Exclusive workspace lock acquired BEFORE building the mobile-mcp connection so
62
+ // a double-spawn loser fails fast and exits before it ever spawns a child.
63
+ function lockPath(projectRoot) {
64
+ return path.join(sessionDir(projectRoot), LOCK_NAME);
65
+ }
66
+
67
+ module.exports = {
68
+ SESSION_DIRNAME,
69
+ SOCKET_NAME,
70
+ PID_NAME,
71
+ HANDLE_NAME,
72
+ LOCK_NAME,
73
+ SOCKET_PATH_LIMIT,
74
+ sessionDir,
75
+ workspaceSocketPath,
76
+ socketPath,
77
+ pidFilePath,
78
+ handlePath,
79
+ lockPath,
80
+ };
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ // Newline-delimited JSON framing for the device session daemon protocol.
4
+ //
5
+ // The client sends request frames; the daemon replies with response frames.
6
+ // Each frame is a single JSON object terminated by exactly one '\n'. Requests
7
+ // carry an `id` so a client can correlate responses on a multiplexed socket.
8
+ //
9
+ // Request frames:
10
+ // { id, type: 'call', tool, args } — route a mobile-mcp tool call
11
+ // { id, type: 'shutdown' } — control: ask the daemon to exit
12
+ // { id, type: 'ping' } — liveness probe
13
+ //
14
+ // Response frames:
15
+ // { id, ok: true, result }
16
+ // { id, ok: false, error: { message } }
17
+ //
18
+ // FrameParser owns the framing on both sides: encode() writes a frame, push()
19
+ // buffers partial chunks and yields whole frames. A malformed line is REPORTED
20
+ // (yielded as an error frame), never thrown — a single bad line must not kill
21
+ // the connection. (The encode/decode of a single frame is a JSON.stringify /
22
+ // JSON.parse one-liner with no independent reason to live as separate exports,
23
+ // so they fold into the one module that earns its keep.)
24
+
25
+ const NL = '\n';
26
+
27
+ // Buffers byte chunks and yields one parsed object per complete '\n'-terminated
28
+ // line. push() returns an array of results; each result is either
29
+ // { value } for a parsed object or { error, line } for a malformed line.
30
+ class FrameParser {
31
+ // Serialize a request/response object to one newline-terminated wire frame.
32
+ static encode(obj) {
33
+ return JSON.stringify(obj) + NL;
34
+ }
35
+
36
+ constructor() {
37
+ this._buf = '';
38
+ }
39
+
40
+ push(chunk) {
41
+ this._buf += chunk == null ? '' : String(chunk);
42
+ const out = [];
43
+ let idx;
44
+ while ((idx = this._buf.indexOf(NL)) !== -1) {
45
+ const line = this._buf.slice(0, idx);
46
+ this._buf = this._buf.slice(idx + 1);
47
+ if (line.length === 0) continue; // tolerate blank lines
48
+ try {
49
+ out.push({ value: JSON.parse(line) });
50
+ } catch (err) {
51
+ out.push({ error: err, line });
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+ }
57
+
58
+ module.exports = {
59
+ FrameParser,
60
+ };