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,100 @@
1
+ 'use strict';
2
+
3
+ const { ALLOW_LABELS, DENY_LABELS, findByLabels } = require('./labels');
4
+
5
+ // Raised when a semantic action cannot be performed on the current screen/
6
+ // platform. `.kind`/`.hint` let the CLI map it onto the `device` envelope.
7
+ class SemanticResolutionError extends Error {
8
+ constructor(message, hint) {
9
+ super(message);
10
+ this.name = 'SemanticResolutionError';
11
+ this.kind = 'device';
12
+ this.hint = hint;
13
+ }
14
+ }
15
+
16
+ // iOS return/confirm keys that dismiss the keyboard when tapped.
17
+ const IOS_RETURN_KEYS = ['return', 'done', 'go', 'search', 'send', 'next'];
18
+
19
+ class SemanticResolver {
20
+ constructor(bridge) {
21
+ this.bridge = bridge;
22
+ }
23
+
24
+ async _tapByLabels(labels, intent) {
25
+ const el = findByLabels(await this.bridge.listElements(), labels);
26
+ if (!el) {
27
+ throw new SemanticResolutionError(
28
+ `Could not find a "${intent}" affordance on screen.`,
29
+ 'Ensure the relevant system dialog/control is visible before this step.'
30
+ );
31
+ }
32
+ await this.bridge.tap({ x: el.element.center[0], y: el.element.center[1] });
33
+ return { mechanism: `tap:${el.label}` };
34
+ }
35
+ }
36
+
37
+ class AndroidResolver extends SemanticResolver {
38
+ async pressBack() {
39
+ await this.bridge.pressButton('BACK');
40
+ return { mechanism: 'button:BACK' };
41
+ }
42
+
43
+ // BACK also hides the IME on Android. Caveat: with no keyboard open this pops
44
+ // the screen — accepted; mobile-mcp exposes no reliable IME-visible signal.
45
+ async dismissKeyboard() {
46
+ await this.bridge.pressButton('BACK');
47
+ return { mechanism: 'button:BACK' };
48
+ }
49
+
50
+ async grantPermission() {
51
+ return this._tapByLabels(ALLOW_LABELS.android, 'grant permission');
52
+ }
53
+
54
+ async denyPermission() {
55
+ return this._tapByLabels(DENY_LABELS.android, 'deny permission');
56
+ }
57
+ }
58
+
59
+ class IOSResolver extends SemanticResolver {
60
+ // No hardware back on iOS: left-edge interactive-pop gesture.
61
+ async pressBack() {
62
+ const { width, height } = await this.bridge.getScreenSize();
63
+ // Without a real screen size the swipe would be a degenerate no-op that
64
+ // still reports success — hard-fail instead of silently doing nothing.
65
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
66
+ throw new SemanticResolutionError(
67
+ `Cannot perform the iOS back gesture without a valid screen size (got ${width}x${height}).`,
68
+ 'Ensure a device or simulator is connected.'
69
+ );
70
+ }
71
+ await this.bridge.swipe({
72
+ direction: 'right',
73
+ x: 1,
74
+ y: Math.round(height / 2),
75
+ distance: Math.round(width * 0.6),
76
+ });
77
+ return { mechanism: 'edge_swipe' };
78
+ }
79
+
80
+ // Primary: tap a return/Done key. Fallback: swipe down to dismiss.
81
+ async dismissKeyboard() {
82
+ const el = findByLabels(await this.bridge.listElements(), IOS_RETURN_KEYS);
83
+ if (el) {
84
+ await this.bridge.tap({ x: el.element.center[0], y: el.element.center[1] });
85
+ return { mechanism: `tap:${el.label}` };
86
+ }
87
+ await this.bridge.swipe({ direction: 'down' });
88
+ return { mechanism: 'swipe:down' };
89
+ }
90
+
91
+ async grantPermission() {
92
+ return this._tapByLabels(ALLOW_LABELS.ios, 'grant permission');
93
+ }
94
+
95
+ async denyPermission() {
96
+ return this._tapByLabels(DENY_LABELS.ios, 'deny permission');
97
+ }
98
+ }
99
+
100
+ module.exports = { SemanticResolver, AndroidResolver, IOSResolver, SemanticResolutionError };
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ // Client side of the device session daemon protocol.
4
+ //
5
+ // tryConnect(projectRoot) returns a { call, close } pair shaped EXACTLY like
6
+ // mobile-mcp-client.createCall, so a daemon-backed bridge is a drop-in for a
7
+ // one-shot bridge. Returns null when no live daemon is reachable (the caller
8
+ // then falls back to a one-shot spawn). Requests are correlated by id so a
9
+ // single connection can multiplex concurrent calls.
10
+
11
+ const net = require('net');
12
+
13
+ const paths = require('./session-paths');
14
+ const { FrameParser } = require('./session-protocol');
15
+
16
+ const DEFAULT_TIMEOUT_MS = 30000;
17
+
18
+ // Connect a socket to the daemon, or resolve null if it cannot connect.
19
+ function connectSocket(socketPath) {
20
+ return new Promise((resolve) => {
21
+ const socket = net.createConnection(socketPath);
22
+ let settled = false;
23
+ const done = (val) => {
24
+ if (settled) return;
25
+ settled = true;
26
+ resolve(val);
27
+ };
28
+ socket.once('connect', () => done(socket));
29
+ socket.once('error', () => done(null));
30
+ });
31
+ }
32
+
33
+ // Build a { call, close, request } client over an already-connected socket.
34
+ function wrapSocket(socket) {
35
+ const parser = new FrameParser();
36
+ const pending = new Map();
37
+ let nextId = 1;
38
+ let closed = false;
39
+
40
+ socket.setEncoding('utf8');
41
+ socket.on('data', (chunk) => {
42
+ for (const f of parser.push(chunk)) {
43
+ if (f.error) continue; // ignore malformed responses
44
+ const res = f.value;
45
+ const entry = pending.get(res.id);
46
+ if (entry) {
47
+ pending.delete(res.id);
48
+ entry.resolve(res);
49
+ }
50
+ }
51
+ });
52
+
53
+ const failAll = (err) => {
54
+ for (const [, entry] of pending) entry.reject(err);
55
+ pending.clear();
56
+ };
57
+ socket.on('error', (err) => failAll(err));
58
+ socket.on('close', () => {
59
+ if (!closed) failAll(new Error('session daemon connection closed'));
60
+ });
61
+
62
+ function request(frame, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
63
+ const id = nextId++;
64
+ return new Promise((resolve, reject) => {
65
+ const timer = setTimeout(() => {
66
+ pending.delete(id);
67
+ reject(new Error('session daemon request timed out'));
68
+ }, timeoutMs);
69
+ if (typeof timer.unref === 'function') timer.unref();
70
+ pending.set(id, {
71
+ resolve: (res) => {
72
+ clearTimeout(timer);
73
+ resolve(res);
74
+ },
75
+ reject: (err) => {
76
+ clearTimeout(timer);
77
+ reject(err);
78
+ },
79
+ });
80
+ socket.write(FrameParser.encode({ id, ...frame }));
81
+ });
82
+ }
83
+
84
+ async function call(tool, args = {}) {
85
+ const res = await request({ type: 'call', tool, args });
86
+ if (!res.ok) {
87
+ // Carry the daemon's kind/hint (e.g. kind:'timeout' + partial-execution
88
+ // hint) so the CLI can surface them instead of defaulting to 'device'.
89
+ const e = new Error((res.error && res.error.message) || 'session call failed');
90
+ if (res.error && res.error.kind) e.kind = res.error.kind;
91
+ if (res.error && res.error.hint) e.hint = res.error.hint;
92
+ throw e;
93
+ }
94
+ return res.result;
95
+ }
96
+
97
+ function close() {
98
+ closed = true;
99
+ try {
100
+ socket.end();
101
+ } catch (_) {
102
+ /* ignore */
103
+ }
104
+ return Promise.resolve();
105
+ }
106
+
107
+ return { call, close, request };
108
+ }
109
+
110
+ // Returns { call, close } against a live daemon, or null when unreachable.
111
+ async function tryConnect(projectRoot) {
112
+ const socket = await connectSocket(paths.socketPath(projectRoot));
113
+ if (!socket) return null;
114
+ const { call, close } = wrapSocket(socket);
115
+ return { call, close };
116
+ }
117
+
118
+ // Liveness probe: connect + ping. Returns the handle device on success, false
119
+ // otherwise. Used to decide daemon-vs-oneshot and to poll a spawned daemon.
120
+ async function isAlive(projectRoot) {
121
+ const socket = await connectSocket(paths.socketPath(projectRoot));
122
+ if (!socket) return false;
123
+ const client = wrapSocket(socket);
124
+ try {
125
+ const res = await client.request({ type: 'ping' }, { timeoutMs: 2000 });
126
+ return !!(res && res.ok);
127
+ } catch (_) {
128
+ return false;
129
+ } finally {
130
+ await client.close();
131
+ }
132
+ }
133
+
134
+ // Ask a live daemon to shut down. Resolves true when a daemon acknowledged,
135
+ // false when none was reachable.
136
+ async function requestShutdown(projectRoot) {
137
+ const socket = await connectSocket(paths.socketPath(projectRoot));
138
+ if (!socket) return false;
139
+ const client = wrapSocket(socket);
140
+ try {
141
+ const res = await client.request({ type: 'shutdown' }, { timeoutMs: 5000 });
142
+ return !!(res && res.ok);
143
+ } catch (_) {
144
+ return false;
145
+ } finally {
146
+ await client.close();
147
+ }
148
+ }
149
+
150
+ // Rich status probe: connect + ping and report whether a daemon is running,
151
+ // how many device calls are currently in flight (the double-execution window
152
+ // is visible here), and which device it is pinned to. `null`s when not running.
153
+ async function getSessionStatus(projectRoot) {
154
+ const socket = await connectSocket(paths.socketPath(projectRoot));
155
+ if (!socket) return { running: false, in_flight: null, device: null };
156
+ const client = wrapSocket(socket);
157
+ try {
158
+ const res = await client.request({ type: 'ping' }, { timeoutMs: 2000 });
159
+ if (!(res && res.ok)) return { running: false, in_flight: null, device: null };
160
+ const result = res.result || {};
161
+ return {
162
+ running: true,
163
+ in_flight: typeof result.in_flight === 'number' ? result.in_flight : null,
164
+ device: result.device != null ? result.device : null,
165
+ };
166
+ } catch (_) {
167
+ return { running: false, in_flight: null, device: null };
168
+ } finally {
169
+ await client.close();
170
+ }
171
+ }
172
+
173
+ module.exports = { tryConnect, isAlive, requestShutdown, getSessionStatus, DEFAULT_TIMEOUT_MS };