mobile-automator 0.27.1 → 0.28.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-automator",
3
- "version": "0.27.1",
3
+ "version": "0.28.1",
4
4
  "description": "Host-agnostic mauto CLI for AI-driven mobile QA automation — analyzes a mobile project and drives devices through mobile-mcp.",
5
5
  "homepage": "https://github.com/sh3lan93/mobile-automator#readme",
6
6
  "repository": {
@@ -41,8 +41,7 @@
41
41
  "devDependencies": {
42
42
  "jest": "^29.7.0",
43
43
  "jest-environment-jsdom": "^29.7.0",
44
- "js-yaml": "^4.3.2",
45
- "toml": "^3.0.0"
44
+ "js-yaml": "^4.3.2"
46
45
  },
47
46
  "engines": {
48
47
  "node": ">=22.12.0"
package/src/cli.js CHANGED
@@ -1234,17 +1234,54 @@ async function handleSessionStatus(
1234
1234
  { projectRoot, client = require('./device/session-client') } = {}
1235
1235
  ) {
1236
1236
  const status = await connection.sessionStatus(projectRoot, { client });
1237
- return { envelope: ok(status), exitKind: 'ok' };
1237
+ const hint =
1238
+ status.reason === 'engine_exited'
1239
+ ? `The device engine exited; this session is shutting down and the next verb starts a fresh one. ${connection.daemonLogHint(projectRoot)}`
1240
+ : null;
1241
+ return { envelope: ok(status, hint), exitKind: 'ok' };
1238
1242
  }
1239
1243
 
1244
+ // endSession's failure outcomes (#156), each with the hint that is true for it.
1245
+ // All three may leave a daemon alive and holding the workspace lock; what
1246
+ // differs is WHY, and so what the user should do about it. One vague hint used
1247
+ // to cover all three.
1248
+ const SESSION_END_FAILURE_HINTS = {
1249
+ survived: (pid, pidFile) =>
1250
+ `It is still alive after SIGTERM and SIGKILL and may still hold the workspace lock. Check it with \`ps -p ${pid}\` and stop it with \`kill -9 ${pid}\` (pidfile: ${pidFile}).`,
1251
+ foreign: (pid, pidFile) =>
1252
+ `It answered the shutdown, but its command line does not name this workspace (possibly started by mauto < 0.28), so it was not signalled. Check it with \`ps -p ${pid}\` and stop it manually (pidfile: ${pidFile}).`,
1253
+ unverified: (pid, pidFile) =>
1254
+ `It could not be identified (\`ps\` failed), so it was not signalled. Check it with \`ps -p ${pid}\` and stop it manually if it is this workspace's mauto daemon (pidfile: ${pidFile}).`,
1255
+ };
1256
+
1240
1257
  async function handleSessionEnd(
1241
- { projectRoot, client = require('./device/session-client') } = {}
1258
+ { projectRoot, client = require('./device/session-client'), end = {} } = {}
1242
1259
  ) {
1243
- const stopped = await connection.endSession(projectRoot, { client });
1244
- return {
1245
- envelope: ok({ stopped, already_stopped: !stopped }),
1246
- exitKind: 'ok',
1260
+ const { outcome, pid } = await connection.endSession(projectRoot, { client, ...end });
1261
+ const logHint = connection.daemonLogHint(projectRoot);
1262
+ if (Object.prototype.hasOwnProperty.call(SESSION_END_FAILURE_HINTS, outcome)) {
1263
+ const pidFile = require('./device/session-paths').pidFilePath(projectRoot);
1264
+ return {
1265
+ envelope: fail(
1266
+ 'device',
1267
+ `device session daemon pid ${pid} did not exit`,
1268
+ `${SESSION_END_FAILURE_HINTS[outcome](pid, pidFile)} ${logHint}`,
1269
+ { stopped: false, already_stopped: false, pid }
1270
+ ),
1271
+ exitKind: 'device',
1272
+ };
1273
+ }
1274
+ // 'stopped' | 'forced' | 'already_stopped'. The ok envelope keeps the boolean
1275
+ // shape it had before endSession returned an outcome.
1276
+ const data = {
1277
+ stopped: outcome === 'stopped' || outcome === 'forced',
1278
+ already_stopped: outcome === 'already_stopped',
1279
+ forced: outcome === 'forced',
1247
1280
  };
1281
+ const hint = outcome === 'forced'
1282
+ ? `The daemon did not exit after shutdown; terminated pid ${pid}. ${logHint}`
1283
+ : null;
1284
+ return { envelope: ok(data, hint), exitKind: 'ok' };
1248
1285
  }
1249
1286
 
1250
1287
  // --- Issue #92: device discovery + persisted selection -------------------
@@ -19,6 +19,9 @@ const { daemonLogHint } = require('./session-log');
19
19
  const paths = require('./session-paths');
20
20
  const sessionClient = require('./session-client');
21
21
  const sessionSpawn = require('./session-spawn');
22
+ const sessionDaemon = require('./session-daemon');
23
+ const processIdentity = require('./process-identity');
24
+ const processReaper = require('./process-reaper');
22
25
 
23
26
  // Acquire a device connection, transparently reusing a live daemon when one
24
27
  // fits and falling back to a one-shot mobile-mcp spawn otherwise. Returns
@@ -63,6 +66,8 @@ async function sessionStatus(projectRoot, { client = sessionClient } = {}) {
63
66
  const status = await client.getSessionStatus(projectRoot);
64
67
  return {
65
68
  ...status,
69
+ // Always present (#156): null, or why a reachable daemon is not running.
70
+ reason: (status && status.reason) || null,
66
71
  log_path: paths.logFilePath(projectRoot),
67
72
  // Read from the handle, not from the ping reply. The handle is written once
68
73
  // the daemon is listening and removed by stop(), so it is the same fact
@@ -82,9 +87,72 @@ function startSession({ projectRoot, device = null, idleMs, spawn = sessionSpawn
82
87
  return spawn.spawnDaemon({ projectRoot, device, idleMs });
83
88
  }
84
89
 
85
- // Ask a live daemon to shut down. Resolves true when one acknowledged.
86
- function endSession(projectRoot, { client = sessionClient } = {}) {
87
- return client.requestShutdown(projectRoot);
90
+ // How long `session end` waits for an acknowledged daemon to exit before it
91
+ // treats it as stuck: stop() drains in-flight calls (bounded by the daemon call
92
+ // timeout) and then closes the engine (the SDK's ~4s ladder). Derived, not
93
+ // restated; pinned by tests/unit/device/timeout-invariant.test.js.
94
+ const END_BUDGET_MS = sessionDaemon.DAEMON_CALL_TIMEOUT_MS + 10000;
95
+
96
+ // Stop the daemon and report only what is true (#156). The daemon acks the
97
+ // shutdown frame BEFORE its teardown runs, so an ack is a promise, not a fact:
98
+ // 'stopped' is reported only once the pid is gone.
99
+ //
100
+ // Returns { outcome, pid } — ONE discriminated answer, so a caller dispatches
101
+ // on what happened instead of re-deriving it from a bag of booleans:
102
+ // 'stopped' - acked, and the pid exited (or there was no pid to watch)
103
+ // 'already_stopped' - nothing acked and nothing of ours was running
104
+ // 'forced' - ours, stuck in teardown, terminated by a group kill
105
+ // 'survived' - ours, stuck, and still alive after SIGTERM + SIGKILL
106
+ // 'foreign' - acked, but the live pid's command line does not name
107
+ // this workspace, so it was reported, never signalled
108
+ // 'unverified' - the pid could not be identified (`ps` failed), so it
109
+ // was never signalled
110
+ // The last three are failures: a daemon may still be alive.
111
+ async function endSession(
112
+ projectRoot,
113
+ {
114
+ client = sessionClient,
115
+ reaper = processReaper,
116
+ // Bound to THIS workspace (#156): a group kill must never reach another
117
+ // workspace's daemon through a copied or recycled pidfile.
118
+ identify = (pid) => processIdentity.pidIdentity(pid, { projectRoot }),
119
+ readPid = () => processIdentity.readPidFile(paths.pidFilePath(projectRoot)),
120
+ budgetMs = END_BUDGET_MS,
121
+ } = {}
122
+ ) {
123
+ // Read BEFORE asking: a daemon that exits cleanly removes its pidfile, and we
124
+ // need to know whom to watch.
125
+ const pid = readPid();
126
+ const acked = await client.requestShutdown(projectRoot);
127
+ const report = (outcome) => ({ outcome, pid: pid || null });
128
+ // The daemon is gone: it stopped because we asked, or was never running.
129
+ const gone = () => report(acked ? 'stopped' : 'already_stopped');
130
+
131
+ // No pidfile: nothing to watch, so the ack is all we have (as before). Our
132
+ // own pid: the in-process daemon of a test — never wait on it, never signal it.
133
+ if (!pid || pid === process.pid) return gone();
134
+
135
+ // A non-acking daemon whose pidfile names a live process is checked at once
136
+ // (budget 0): either it is gone, or it is stuck too badly to answer.
137
+ if (await reaper.waitForDeath(pid, { budgetMs: acked ? budgetMs : 0 })) return gone();
138
+
139
+ switch (identify(pid)) {
140
+ case 'dead':
141
+ case 'other': // the pid was recycled: our daemon is gone
142
+ return gone();
143
+ case 'foreign':
144
+ // A mauto daemon whose command line does not name this workspace:
145
+ // another workspace's, reached through a copied or recycled pidfile.
146
+ // Never signalled. Without an ack nothing answered on OUR socket, so none
147
+ // of ours was running. With an ack the daemon that answered is ours, but
148
+ // its command line cannot prove it (one started before the workspace
149
+ // marker), so it is reported rather than killed.
150
+ return report(acked ? 'foreign' : 'already_stopped');
151
+ case 'ours':
152
+ return report((await reaper.terminate(pid, { group: true })) === 'dead' ? 'forced' : 'survived');
153
+ default: // 'unknown': never signal a process we cannot identify
154
+ return report('unverified');
155
+ }
88
156
  }
89
157
 
90
158
  module.exports = {
@@ -97,4 +165,5 @@ module.exports = {
97
165
  sessionStatus,
98
166
  startSession,
99
167
  endSession,
168
+ END_BUDGET_MS,
100
169
  };
@@ -6,6 +6,26 @@ const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio
6
6
  const { injectDeviceArg } = require('./tool-args');
7
7
  const { resolveSingleDevice } = require('./device-resolver');
8
8
  const { normalizeDevices } = require('./device-model');
9
+ const { ErrorCode } = require('@modelcontextprotocol/sdk/types.js');
10
+
11
+ // Bound on mobile-mcp's MCP initialize handshake (#156). Measured at ~200ms on
12
+ // mobile-mcp 1.0.5 — initialize touches no device — so 10s is ~50x headroom.
13
+ // The spawner kills an unready daemon when its readiness window closes, and a
14
+ // failed connect is reported only once the engine is reaped, so
15
+ // CONNECT_TIMEOUT_MS + ENGINE_REAP_MAX_MS, plus headroom for the daemon's own
16
+ // boot, MUST stay below session-spawn's DEFAULT_READY_TIMEOUT_MS — or the
17
+ // daemon is killed instead of failing with its own daemon.connect_failure.
18
+ // Pinned by tests/unit/device/timeout-invariant.test.js.
19
+ // (The SDK's own default is 60s, which is longer than the window watching it.)
20
+ const CONNECT_TIMEOUT_MS = 10000;
21
+
22
+ // A failed connect terminates the engine at once rather than waiting out the
23
+ // SDK's fire-and-forget close (stdin end → 2s → SIGTERM → 2s → SIGKILL): an
24
+ // engine that never initialized has nothing to flush, and every second spent
25
+ // here is spent inside the spawner's readiness window. SIGTERM, SIGKILL after
26
+ // ENGINE_KILL_GRACE_MS, then up to ENGINE_KILL_GRACE_MS more for the death.
27
+ const ENGINE_KILL_GRACE_MS = 1000;
28
+ const ENGINE_REAP_MAX_MS = 2 * ENGINE_KILL_GRACE_MS;
9
29
 
10
30
  // Resolve the mobile-mcp server entry point from the bundled, pinned
11
31
  // dependency rather than fetching `@latest` at runtime. We read the package's
@@ -26,25 +46,92 @@ function resolveServerEntry() {
26
46
  return path.join(pkgDir, binRel);
27
47
  }
28
48
 
49
+ // The environment mauto's own engine process is spawned with. Pure: returns a
50
+ // new object and never mutates baseEnv.
51
+ //
52
+ // MOBILEMCP_ALLOW_UNSAFE_URLS defaults to "1" (#212). mobile-mcp rejects every
53
+ // non-http(s) URL in mobile_open_url unless it is set, which blocks the
54
+ // documented `open_url` deep-link capability (myapp://...). That guard exists to
55
+ // stop an LLM whose ONLY reach is MCP from opening arbitrary schemes; an agent
56
+ // driving mauto already has a shell and could open the same URL via adb or
57
+ // simctl, so under mauto it protects nothing. The URL reaches the device as an
58
+ // argv element on the default mobilecli robot (no shell), and the legacy adb
59
+ // robot escapes shell metacharacters. A user-set value wins: the engine only
60
+ // accepts the exact string "1", so MOBILEMCP_ALLOW_UNSAFE_URLS=0 restores the
61
+ // guard. The env is read when the engine spawns, so a running session daemon
62
+ // must be ended (`mauto session end`) before a change takes effect.
63
+ function buildEngineEnv({ device = null, baseEnv = process.env } = {}) {
64
+ const env = {
65
+ ...baseEnv,
66
+ MOBILEMCP_ALLOW_UNSAFE_URLS: baseEnv.MOBILEMCP_ALLOW_UNSAFE_URLS ?? '1',
67
+ };
68
+ // mobile-mcp reads the target device from the environment / its own
69
+ // selection; we pass it through when provided so callers can pin a device.
70
+ if (device) env.MOBILE_MCP_DEVICE = device;
71
+ return env;
72
+ }
73
+
74
+ // The SDK transport, remembering the engine pid the moment the child is spawned
75
+ // (#156). It has to be remembered: on a failed connect the SDK runs
76
+ // `void this.close()`, and StdioClientTransport.close() clears its pid
77
+ // synchronously — so by the time createCall's catch runs, `pid` is already null
78
+ // while the child may still be alive. `spawnedPid` is never cleared.
79
+ // Client.connect() awaits transport.start(), which is what makes start() the
80
+ // place to read it; tests/integration/engine-lifecycle.test.js pins that against
81
+ // the real SDK (a failed connect still reaps the engine).
82
+ function pidTracking(Transport) {
83
+ return class PidTrackingTransport extends Transport {
84
+ async start() {
85
+ await super.start();
86
+ this.spawnedPid = this.pid;
87
+ }
88
+ };
89
+ }
90
+
29
91
  // Integration glue: spawns mobile-mcp over stdio and returns a `call`
30
92
  // function shaped exactly like the one DeviceBridge expects.
31
- // NOT unit-tested (would require spawning mobile-mcp); see the smoke test.
32
- async function createCall({ device } = {}) {
33
- const serverEntry = resolveServerEntry();
34
- const transport = new StdioClientTransport({
35
- command: process.execPath,
36
- args: [serverEntry],
37
- // mobile-mcp reads the target device from the environment / its own
38
- // selection; we pass it through when provided so callers can pin a device.
39
- env: device ? { ...process.env, MOBILE_MCP_DEVICE: device } : process.env,
93
+ //
94
+ // `sdk`, `server` and `reaper` are test seams: production passes none of them.
95
+ // tests/unit/device/mobile-mcp-client-lifecycle.test.js drives this against a
96
+ // fake SDK; tests/integration/engine-lifecycle.test.js against the real SDK and
97
+ // a fixture MCP server.
98
+ async function createCall({
99
+ device,
100
+ connectTimeoutMs = CONNECT_TIMEOUT_MS,
101
+ onEngineExit = null,
102
+ sdk = { Client, StdioClientTransport },
103
+ server = null,
104
+ reaper = require('./process-reaper'),
105
+ } = {}) {
106
+ // Resolved lazily, here, so the module still loads without mobile-mcp installed.
107
+ const { command, args } = server || { command: process.execPath, args: [resolveServerEntry()] };
108
+ const Transport = pidTracking(sdk.StdioClientTransport);
109
+ const transport = new Transport({
110
+ command,
111
+ args,
112
+ env: buildEngineEnv({ device }),
40
113
  });
41
114
 
42
- const client = new Client(
43
- { name: 'mauto', version: '0.1.0' },
44
- { capabilities: {} }
45
- );
115
+ const client = new sdk.Client({ name: 'mauto', version: '0.1.0' }, { capabilities: {} });
116
+
117
+ // Engine liveness (#156). The SDK fires onclose on EVERY child close — a real
118
+ // engine death and our own close() alike — so `closing` separates the two.
119
+ // A failed connect also sets it: that failure is reported by the throw.
120
+ let closing = false;
121
+ let reported = false;
122
+ client.onclose = () => {
123
+ if (closing || reported) return;
124
+ reported = true;
125
+ if (typeof onEngineExit === 'function') onEngineExit();
126
+ };
46
127
 
47
- await client.connect(transport);
128
+ try {
129
+ await client.connect(transport, { timeout: connectTimeoutMs });
130
+ } catch (err) {
131
+ closing = true;
132
+ await reapEngine(transport.spawnedPid, reaper);
133
+ throw connectError(err, connectTimeoutMs);
134
+ }
48
135
 
49
136
  async function rawCall(toolName, args = {}) {
50
137
  const res = await client.callTool({ name: toolName, arguments: args });
@@ -53,6 +140,7 @@ async function createCall({ device } = {}) {
53
140
  const { call } = makeCall({ rawCall, device });
54
141
 
55
142
  async function close() {
143
+ closing = true;
56
144
  try {
57
145
  await client.close();
58
146
  } catch (_e) {
@@ -63,6 +151,27 @@ async function createCall({ device } = {}) {
63
151
  return { call, close };
64
152
  }
65
153
 
154
+ // The SDK's connect-failure cleanup is fire-and-forget, so the engine is
155
+ // terminated here, at once, and the error leaves createCall only once it is
156
+ // gone — nothing may outlive the failure it reports. The budget-0 check first:
157
+ // an engine that already exited (the ConnectionClosed path) is not signalled.
158
+ // group:false: the engine is not a process-group leader.
159
+ async function reapEngine(pid, reaper) {
160
+ if (!pid) return;
161
+ if (await reaper.waitForDeath(pid, { budgetMs: 0 })) return;
162
+ await reaper.terminate(pid, { group: false, graceMs: ENGINE_KILL_GRACE_MS });
163
+ }
164
+
165
+ // A timed-out initialize becomes an error that names the bound; anything else
166
+ // (spawn ENOENT, a protocol mismatch) passes through untouched.
167
+ function connectError(err, timeoutMs) {
168
+ if (!(err && err.code === ErrorCode.RequestTimeout)) return err;
169
+ const e = new Error(`mobile-mcp did not finish initializing within ${timeoutMs}ms`);
170
+ e.code = 'ENGINE_CONNECT_TIMEOUT';
171
+ e.cause = err;
172
+ return e;
173
+ }
174
+
66
175
  // Pure orchestration over a low-level rawCall(toolName, args) => Promise<parsed>.
67
176
  // Threads a concrete device id into every tool call except the discovery verb,
68
177
  // lazily auto-discovering a single device when none was pinned (cached after
@@ -162,4 +271,12 @@ function parseToolResult(res) {
162
271
 
163
272
  // resolveServerEntry is exported so tests/integration/mobile-mcp-contract.test.js
164
273
  // spawns the pinned engine by the exact path production does, not a copy of it.
165
- module.exports = { createCall, makeCall, parseToolResult, resolveServerEntry };
274
+ module.exports = {
275
+ createCall,
276
+ makeCall,
277
+ parseToolResult,
278
+ resolveServerEntry,
279
+ CONNECT_TIMEOUT_MS,
280
+ ENGINE_REAP_MAX_MS,
281
+ buildEngineEnv,
282
+ };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ // Process identity for the device session lifecycle (#156): is a pid alive,
4
+ // is it a zombie awaiting a reaper, is it OUR daemon, and which pid does a
5
+ // pid/lock file name. A leaf module on purpose — session-daemon.js (cleanStale),
6
+ // process-reaper.js (its waits) and connection.js (`session end`) all ask these
7
+ // questions, and none of them should have to load the daemon to do so.
8
+ //
9
+ // Every effect that can fail on a slim host (`ps`, /proc) is injectable, so the
10
+ // classification is unit-testable against a fake process table.
11
+
12
+ const childProcess = require('child_process');
13
+ const fs = require('fs');
14
+
15
+ const paths = require('./session-paths');
16
+
17
+ // True when a pid is a live process we own. ESRCH => dead; EPERM => alive.
18
+ // cleanStale routes through pidIdentity, which also checks the process's
19
+ // identity (not just liveness).
20
+ function pidAlive(pid) {
21
+ if (!pid || !Number.isInteger(pid)) return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (err) {
26
+ return err.code === 'EPERM';
27
+ }
28
+ }
29
+
30
+ // Has `pid` exited and is only awaiting a parent to reap it? kill(pid, 0)
31
+ // succeeds on a zombie, so pidAlive alone calls an exited process alive (#156).
32
+ // That is the normal end state of a daemon wherever PID 1 does not reap
33
+ // adopted orphans — Docker without --init, CI job containers whose PID 1 is
34
+ // `tail -f /dev/null`, a Node agent host running as PID 1 — because the
35
+ // detached daemon is reparented to it when the verb that spawned it exits.
36
+ // Any failure to tell answers "not a zombie", so liveness decides as before.
37
+ // Used by process-reaper.js's waits; lives here, beside pidAlive and
38
+ // pidIdentity, because this is the module that runs `ps`.
39
+ function isZombie(
40
+ pid,
41
+ { platform = process.platform, readFile = fs.readFileSync, execFile = childProcess.execFileSync } = {}
42
+ ) {
43
+ try {
44
+ if (platform === 'linux') {
45
+ // "pid (comm) S ..." — comm may itself contain spaces or ')', so the state
46
+ // is the field after the LAST ')'. Reading /proc needs no `ps`, which
47
+ // slim container images do not ship.
48
+ const stat = String(readFile(`/proc/${pid}/stat`, 'utf8'));
49
+ return /^\s*[ZX]/.test(stat.slice(stat.lastIndexOf(')') + 1));
50
+ }
51
+ const out = execFile('ps', ['-o', 'stat=', '-p', String(pid)], {
52
+ encoding: 'utf8',
53
+ stdio: ['ignore', 'pipe', 'ignore'],
54
+ });
55
+ return /^\s*Z/.test(String(out));
56
+ } catch (_) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ // Classify a pid by liveness AND identity. Liveness alone is not enough: a
62
+ // recycled pid (an unrelated process that inherited the dead daemon's pid) is
63
+ // "alive" but is NOT our daemon, so a stale lock/pidfile pointing at it must be
64
+ // cleaned rather than respected. Returns one of:
65
+ // 'dead' - no such process (ESRCH or any non-EPERM kill error)
66
+ // 'ours' - alive AND its command line contains `mauto-session-daemon`
67
+ // (and, when `projectRoot` is given, names THAT workspace)
68
+ // 'foreign' - only with `projectRoot`: alive and a mauto daemon, but its
69
+ // command line does not name that workspace (#156) — another
70
+ // workspace's daemon, one started before the workspace marker
71
+ // existed, or an editor open on the bin. Never ours to signal.
72
+ // 'other' - alive AND its command line does NOT (recycled pid)
73
+ // 'unknown' - alive but `ps` itself failed (e.g. no `ps` on the host) —
74
+ // treated as 'ours' by cleanStale: never clean when unsure.
75
+ // `execFile` is injectable for tests; a failure there must never crash the
76
+ // daemon, hence the 'unknown' fallback.
77
+ //
78
+ // cleanStale passes no projectRoot and keeps the binary-level answer: there a
79
+ // wrong 'ours' only means stale files are left alone. `session end` passes one,
80
+ // because there a wrong 'ours' sends a process-group kill (src/device/connection.js).
81
+ function pidIdentity(pid, { execFile = childProcess.execFileSync, projectRoot = null } = {}) {
82
+ if (!pid || !Number.isInteger(pid)) return 'dead';
83
+ // Our own pid is the in-process daemon (unit tests run startDaemon in the
84
+ // test process, so the lock/pidfile carry the test runner's pid). Never clean
85
+ // files that point at ourselves. In production cleanStale runs before the
86
+ // daemon writes its own pid/lock, so this branch only fires in-process.
87
+ if (pid === process.pid) return 'ours';
88
+ let alive;
89
+ try {
90
+ process.kill(pid, 0);
91
+ alive = true;
92
+ } catch (err) {
93
+ if (err.code === 'EPERM') alive = true;
94
+ else return 'dead';
95
+ }
96
+ if (!alive) return 'dead';
97
+ try {
98
+ // -ww: never truncate the line — the workspace marker is at its END. stderr
99
+ // ignored: a failing `ps` is already the 'unknown' answer, not verb output.
100
+ const out = String(
101
+ execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
102
+ encoding: 'utf8',
103
+ stdio: ['ignore', 'pipe', 'ignore'],
104
+ }) || ''
105
+ );
106
+ if (!/mauto-session-daemon/.test(out)) return 'other';
107
+ if (projectRoot == null) return 'ours';
108
+ return paths.commandNamesWorkspace(out, projectRoot) ? 'ours' : 'foreign';
109
+ } catch (_) {
110
+ return 'unknown';
111
+ }
112
+ }
113
+
114
+ // Read a pid out of a one-line pid/lock file, or null when absent/garbage.
115
+ function readPidFile(file) {
116
+ try {
117
+ const pid = parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
118
+ return Number.isInteger(pid) ? pid : null;
119
+ } catch (_) {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ module.exports = { pidAlive, isZombie, pidIdentity, readPidFile };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ // Process termination for the device session lifecycle (#156). Two callers:
4
+ // session-spawn.js kills a daemon that never became ready, and `session end`
5
+ // (connection.js) escalates a daemon stuck in teardown.
6
+ //
7
+ // Mechanism only. This module never decides WHOSE pid it is signalling — the
8
+ // spawner holds its own ChildProcess, and `session end` runs pidIdentity first.
9
+ // Every effect (kill, liveness, clock) is injected so the escalation policy is
10
+ // tested once, here, against a fake process table.
11
+
12
+ const { pidAlive, isZombie } = require('./process-identity');
13
+
14
+ // Alive, and not merely awaiting a reaper (#156): kill(pid, 0) succeeds on a
15
+ // zombie, so plain liveness waits out a whole budget for a daemon that has
16
+ // already exited wherever PID 1 does not reap (see isZombie). The default
17
+ // liveness for every wait below; the zombie check runs only for a pid that
18
+ // kill(pid, 0) calls alive.
19
+ function processAlive(pid) {
20
+ return pidAlive(pid) && !isZombie(pid);
21
+ }
22
+
23
+ // Deliberately NOT unref'd: `session end` is a one-shot verb process with
24
+ // nothing else on its event loop while it waits, so an unref'd timer would let
25
+ // the process exit mid-wait and report nothing.
26
+ function defaultSleep(ms) {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
29
+
30
+ // Resolves true as soon as `done()` holds, false once `budgetMs` has elapsed.
31
+ // budgetMs 0 is a single check.
32
+ async function waitUntil(done, { budgetMs, pollMs, sleep, now }) {
33
+ const deadline = now() + budgetMs;
34
+ for (;;) {
35
+ if (done()) return true;
36
+ const left = deadline - now();
37
+ if (left <= 0) return false;
38
+ await sleep(Math.min(pollMs, left));
39
+ }
40
+ }
41
+
42
+ // Resolves true as soon as `pid` is dead, false once `budgetMs` has elapsed.
43
+ // budgetMs 0 is a single check.
44
+ function waitForDeath(
45
+ pid,
46
+ { budgetMs, pollMs = 100, isAlive = processAlive, sleep = defaultSleep, now = Date.now } = {}
47
+ ) {
48
+ return waitUntil(() => !isAlive(pid), { budgetMs, pollMs, sleep, now });
49
+ }
50
+
51
+ // Does process group `pgid` still have a member? kill(-pgid, 0) succeeds while
52
+ // any member exists; EPERM means members we may not signal, which still exist.
53
+ function groupExists(kill, pgid) {
54
+ try {
55
+ kill(-pgid, 0);
56
+ return true;
57
+ } catch (err) {
58
+ return !!(err && err.code === 'EPERM');
59
+ }
60
+ }
61
+
62
+ // One signal. 'denied' (EPERM) means a process we may not signal, which is not
63
+ // ours to report as killed. ESRCH is NOT trusted as death: for a group kill it
64
+ // can mean "no such group" while the pid itself is alive, so the caller always
65
+ // confirms with isAlive.
66
+ function send(kill, target, sig) {
67
+ try {
68
+ kill(target, sig);
69
+ return 'sent';
70
+ } catch (err) {
71
+ if (err && err.code === 'EPERM') return 'denied';
72
+ if (err && err.code === 'ESRCH') return 'gone';
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ // SIGTERM, wait up to graceMs, SIGKILL, wait up to graceMs, report.
78
+ //
79
+ // group:true signals -pid, the process GROUP. That only works for a group
80
+ // leader: the daemon is one because session-spawn.js spawns it with
81
+ // `detached: true`, which is also what makes -pid reach the mobile-mcp engine
82
+ // and its mobilecli (spawned non-detached, so in the daemon's group). Remove
83
+ // `detached` there and -pid becomes ESRCH — reported here as 'survived', never
84
+ // as a false 'dead'. group:false is for the engine child, which leads no group.
85
+ //
86
+ // For a group, SIGTERM is enough only once the leader AND every member are
87
+ // gone (#156). The leader alone is not the group: a daemon before listen() has
88
+ // no SIGTERM handler and dies at once, while mobile-mcp installs one that a
89
+ // blocked event loop cannot run — so the engine outlives the leader and must
90
+ // still get the SIGKILL. SIGKILL cannot be caught, so after it the leader's
91
+ // death is the answer (a member left behind is a zombie awaiting its reaper).
92
+ async function terminate(
93
+ pid,
94
+ {
95
+ group = true,
96
+ graceMs = 2000,
97
+ pollMs = 50,
98
+ kill = process.kill.bind(process),
99
+ isAlive = processAlive,
100
+ sleep = defaultSleep,
101
+ now = Date.now,
102
+ } = {}
103
+ ) {
104
+ // kill(0) / kill(-1) / a negative pid would signal our own group or every
105
+ // process we own. Refuse rather than guess. A GROUP kill of pid 1 is
106
+ // kill(-1) — the same broadcast — so it is refused too.
107
+ if (!Number.isInteger(pid) || pid <= 0 || (group && pid === 1)) {
108
+ throw new TypeError(`terminate requires a positive integer pid${group ? ' above 1' : ''}, got ${pid}`);
109
+ }
110
+ const target = group ? -pid : pid;
111
+ const wait = { budgetMs: graceMs, pollMs, sleep, now };
112
+ const gone = group
113
+ ? () => !isAlive(pid) && !groupExists(kill, pid)
114
+ : () => !isAlive(pid);
115
+
116
+ if (send(kill, target, 'SIGTERM') === 'denied') return isAlive(pid) ? 'survived' : 'dead';
117
+ if (await waitUntil(gone, wait)) return 'dead';
118
+ if (send(kill, target, 'SIGKILL') === 'denied') return isAlive(pid) ? 'survived' : 'dead';
119
+ if (await waitUntil(() => !isAlive(pid), wait)) return 'dead';
120
+ return 'survived';
121
+ }
122
+
123
+ module.exports = { waitForDeath, terminate, processAlive };
@@ -150,21 +150,27 @@ async function requestShutdown(projectRoot) {
150
150
  // Rich status probe: connect + ping and report whether a daemon is running,
151
151
  // how many device calls are currently in flight (the double-execution window
152
152
  // is visible here), and which device it is pinned to. `null`s when not running.
153
+ //
154
+ // A daemon whose engine has died is NOT running (#156): it is draining toward
155
+ // its own exit and can never serve a call. Only an explicit engine_alive:false
156
+ // counts — a pre-0.28 daemon omits the field and is treated as alive.
153
157
  async function getSessionStatus(projectRoot) {
158
+ const down = { running: false, reason: null, in_flight: null, device: null };
154
159
  const socket = await connectSocket(paths.socketPath(projectRoot));
155
- if (!socket) return { running: false, in_flight: null, device: null };
160
+ if (!socket) return down;
156
161
  const client = wrapSocket(socket);
157
162
  try {
158
163
  const res = await client.request({ type: 'ping' }, { timeoutMs: 2000 });
159
- if (!(res && res.ok)) return { running: false, in_flight: null, device: null };
164
+ if (!(res && res.ok)) return down;
160
165
  const result = res.result || {};
161
- return {
162
- running: true,
166
+ const seen = {
163
167
  in_flight: typeof result.in_flight === 'number' ? result.in_flight : null,
164
168
  device: result.device != null ? result.device : null,
165
169
  };
170
+ if (result.engine_alive === false) return { running: false, reason: 'engine_exited', ...seen };
171
+ return { running: true, reason: null, ...seen };
166
172
  } catch (_) {
167
- return { running: false, in_flight: null, device: null };
173
+ return down;
168
174
  } finally {
169
175
  await client.close();
170
176
  }
@@ -18,7 +18,6 @@
18
18
  // Everything that touches a real device is injected, so the full lifecycle is
19
19
  // unit-testable with a fake createCall and a mkdtemp project root.
20
20
 
21
- const childProcess = require('child_process');
22
21
  const fs = require('fs');
23
22
  const net = require('net');
24
23
 
@@ -28,6 +27,7 @@ const { newSessionId } = require('./session-handle');
28
27
  const { makeDeviceCall } = require('./device-call');
29
28
  const { safeObserve: guardObserve } = require('../observe/recorder');
30
29
  const { FLUSH_INTERVAL_MS } = require('../observe/flush');
30
+ const { pidIdentity, readPidFile } = require('./process-identity');
31
31
 
32
32
  const DEFAULT_IDLE_MS = 5 * 60 * 1000;
33
33
 
@@ -62,64 +62,6 @@ function safeUnlink(p) {
62
62
  }
63
63
  }
64
64
 
65
- // True when a pid is a live process we own. ESRCH => dead; EPERM => alive.
66
- // Kept exported for back-compat; cleanStale routes through pidIdentity, which
67
- // also checks the process's identity (not just liveness).
68
- function pidAlive(pid) {
69
- if (!pid || !Number.isInteger(pid)) return false;
70
- try {
71
- process.kill(pid, 0);
72
- return true;
73
- } catch (err) {
74
- return err.code === 'EPERM';
75
- }
76
- }
77
-
78
- // Classify a pid by liveness AND identity. Liveness alone is not enough: a
79
- // recycled pid (an unrelated process that inherited the dead daemon's pid) is
80
- // "alive" but is NOT our daemon, so a stale lock/pidfile pointing at it must be
81
- // cleaned rather than respected. Returns one of:
82
- // 'dead' - no such process (ESRCH or any non-EPERM kill error)
83
- // 'ours' - alive AND its command line contains `mauto-session-daemon`
84
- // 'other' - alive AND its command line does NOT (recycled pid)
85
- // 'unknown' - alive but `ps` itself failed (e.g. no `ps` on the host) —
86
- // treated as 'ours' by cleanStale: never clean when unsure.
87
- // `execFile` is injectable for tests; a failure there must never crash the
88
- // daemon, hence the 'unknown' fallback.
89
- function pidIdentity(pid, { execFile = childProcess.execFileSync } = {}) {
90
- if (!pid || !Number.isInteger(pid)) return 'dead';
91
- // Our own pid is the in-process daemon (unit tests run startDaemon in the
92
- // test process, so the lock/pidfile carry the test runner's pid). Never clean
93
- // files that point at ourselves. In production cleanStale runs before the
94
- // daemon writes its own pid/lock, so this branch only fires in-process.
95
- if (pid === process.pid) return 'ours';
96
- let alive;
97
- try {
98
- process.kill(pid, 0);
99
- alive = true;
100
- } catch (err) {
101
- if (err.code === 'EPERM') alive = true;
102
- else return 'dead';
103
- }
104
- if (!alive) return 'dead';
105
- try {
106
- const out = execFile('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
107
- return /mauto-session-daemon/.test(String(out || '')) ? 'ours' : 'other';
108
- } catch (_) {
109
- return 'unknown';
110
- }
111
- }
112
-
113
- // Read a pid out of a one-line pid/lock file, or null when absent/garbage.
114
- function readPidFile(file) {
115
- try {
116
- const pid = parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
117
- return Number.isInteger(pid) ? pid : null;
118
- } catch (_) {
119
- return null;
120
- }
121
- }
122
-
123
65
  // Remove stale socket/pidfile/handle/lock left by a crashed daemon so a fresh
124
66
  // daemon can bind. Identity is decided from the LOCK file first (acquired before
125
67
  // the connection is built, so it covers a daemon still mid-startup) then the
@@ -161,7 +103,9 @@ async function startDaemon({
161
103
  createCall = defaultCreateCall,
162
104
  onUndeliverable = null,
163
105
  scheduleTimeout = defaultScheduleTimeout,
164
- execFile = childProcess.execFileSync,
106
+ // `ps` seam for cleanStale's identity check; undefined keeps
107
+ // process-identity.js's real execFileSync, so this module runs no `ps` itself.
108
+ execFile,
165
109
  // Structured observability, injected as a FACTORY rather than as a ready-made
166
110
  // recorder. See the binding below for why it has to be a factory.
167
111
  //
@@ -296,12 +240,31 @@ async function startDaemon({
296
240
  safeUnlink(lockFile);
297
241
  }
298
242
 
243
+ // Engine liveness (#156). `serving` flips once the daemon is listening and
244
+ // about to write its handle. Before that an engine exit only clears
245
+ // `engineAlive` — the check after listen() below turns it into a failed start,
246
+ // so no handle is ever written for a dead engine. After it, an engine exit
247
+ // reaps the daemon: recorded first and synchronously, like the crash guards,
248
+ // because stop() can block on the in-flight drain. stop() is the existing path
249
+ // unchanged: drain, destroy sockets, close the server, close() (a no-op on a
250
+ // dead engine), unlink, release the lock, resolve whenStopped. The next verb
251
+ // autostarts a fresh daemon.
252
+ let engineAlive = true;
253
+ let serving = false;
254
+ function onEngineExit() {
255
+ if (!engineAlive) return;
256
+ engineAlive = false;
257
+ if (!serving) return;
258
+ safeObserve({ level: 'error', event: 'daemon.engine_exit', dur_ms: Date.now() - startedAtMs });
259
+ stop('engine_exit').catch(() => {});
260
+ }
261
+
299
262
  // Build the single shared connection up front. If it rejects, never leak the
300
263
  // lock (and the child it may have started is the connection's own to clean).
301
264
  let call;
302
265
  let close;
303
266
  try {
304
- ({ call, close } = await createCall({ device }));
267
+ ({ call, close } = await createCall({ device, onEngineExit }));
305
268
  } catch (err) {
306
269
  // #156's core symptom: mobile-mcp never came up, and until now the only
307
270
  // trace was a 15s readiness timeout in the spawning verb.
@@ -599,7 +562,11 @@ async function startDaemon({
599
562
  continue;
600
563
  }
601
564
  if (req.type === 'ping') {
602
- await reply({ id: req.id, ok: true, result: { pong: true, device, in_flight: inFlight } });
565
+ await reply({
566
+ id: req.id,
567
+ ok: true,
568
+ result: { pong: true, device, in_flight: inFlight, engine_alive: engineAlive },
569
+ });
603
570
  armIdle();
604
571
  continue;
605
572
  }
@@ -661,25 +628,23 @@ async function startDaemon({
661
628
 
662
629
  const socketPath = paths.socketPath(projectRoot);
663
630
 
664
- try {
665
- await new Promise((resolve, reject) => {
666
- server.once('error', reject);
667
- server.listen(socketPath, () => {
668
- server.removeListener('error', reject);
669
- resolve();
670
- });
671
- });
672
- } catch (err) {
673
- // listen failed (e.g. EADDRINUSE from a double-spawn that beat us to the
674
- // bind, or a recycled-pid wedge). The connection was already built — close
675
- // it so we never leak the mobile-mcp child, and release the lock.
631
+ // A start that fails once the connection exists: record why, undo what was
632
+ // built (the listening socket, if any, then the engine), release the lock, and
633
+ // rethrow — so a failed start never leaks the mobile-mcp child or wedges the
634
+ // next spawn behind a held lock. The connect failure above has no `close` yet
635
+ // and so does its own, smaller teardown.
636
+ async function abortStart({ event, err, listening }) {
676
637
  safeObserve({
677
638
  level: 'error',
678
- event: 'daemon.listen_failure',
639
+ event,
679
640
  error_code: err && err.code,
680
641
  message: err && (err.message || String(err)),
681
642
  dur_ms: Date.now() - startedAtMs,
682
643
  });
644
+ if (listening) {
645
+ await new Promise((resolve) => server.close(() => resolve()));
646
+ safeUnlink(socketPath);
647
+ }
683
648
  try {
684
649
  await close();
685
650
  } catch (_) {
@@ -689,6 +654,35 @@ async function startDaemon({
689
654
  throw err;
690
655
  }
691
656
 
657
+ try {
658
+ await new Promise((resolve, reject) => {
659
+ server.once('error', reject);
660
+ server.listen(socketPath, () => {
661
+ server.removeListener('error', reject);
662
+ resolve();
663
+ });
664
+ });
665
+ } catch (err) {
666
+ // listen failed (e.g. EADDRINUSE from a double-spawn that beat us to the
667
+ // bind, or a recycled-pid wedge). Nothing is listening, so only the engine
668
+ // and the lock are undone.
669
+ await abortStart({ event: 'daemon.listen_failure', err, listening: false });
670
+ }
671
+
672
+ // The engine died between connect and listen. Fail the start exactly like a
673
+ // listen failure — before the handle exists, so nothing reading it is told a
674
+ // dead session is ready. The bin's main().catch exits 1; the spawner sees a
675
+ // start that never became ready.
676
+ if (!engineAlive) {
677
+ const err = new Error('mobile-mcp exited before the device session daemon was ready');
678
+ err.code = 'ENGINE_EXITED';
679
+ await abortStart({ event: 'daemon.connect_failure', err, listening: true });
680
+ }
681
+
682
+ // Serving from here on: an engine exit reaps the daemon (see onEngineExit).
683
+ // Set synchronously after the check above, so no exit can fall between them.
684
+ serving = true;
685
+
692
686
  // Persist handle + pidfile now that we are actually listening.
693
687
  const handle = {
694
688
  pid: process.pid,
@@ -741,4 +735,9 @@ async function startDaemon({
741
735
  };
742
736
  }
743
737
 
744
- module.exports = { startDaemon, cleanStale, pidAlive, pidIdentity, DAEMON_CALL_TIMEOUT_MS, DEFAULT_IDLE_MS };
738
+ module.exports = {
739
+ startDaemon,
740
+ cleanStale,
741
+ DAEMON_CALL_TIMEOUT_MS,
742
+ DEFAULT_IDLE_MS,
743
+ };
@@ -74,6 +74,26 @@ function lockPath(projectRoot) {
74
74
  return path.join(sessionDir(projectRoot), LOCK_NAME);
75
75
  }
76
76
 
77
+ // The workspace a daemon serves, as it appears on its command line (#156).
78
+ // The daemon itself reads its root from env (MAUTO_SESSION_PROJECT_ROOT), which
79
+ // `ps` cannot see, so every daemon's command line used to be identical. This
80
+ // marker is what lets `session end` tell THIS workspace's daemon from another
81
+ // workspace's before it signals a process group: a copied workspace (`cp -R`
82
+ // carries daemon.pid) or a recycled pid can name a different live daemon.
83
+ // The root is the LAST argument so a match can be anchored at the end.
84
+ const DAEMON_ROOT_FLAG = '--project-root';
85
+
86
+ function daemonArgv(projectRoot) {
87
+ return [DAEMON_ROOT_FLAG, path.resolve(projectRoot)];
88
+ }
89
+
90
+ // Does a `ps -o command=` line name exactly this workspace? Anchored at the end
91
+ // of the line so `/a/proj` never matches a daemon serving `/a/proj2`.
92
+ function commandNamesWorkspace(command, projectRoot) {
93
+ const line = String(command || '').replace(/\s+$/, '');
94
+ return line.endsWith(` ${DAEMON_ROOT_FLAG} ${path.resolve(projectRoot)}`);
95
+ }
96
+
77
97
  module.exports = {
78
98
  SESSION_DIRNAME,
79
99
  SOCKET_NAME,
@@ -89,4 +109,7 @@ module.exports = {
89
109
  pidFilePath,
90
110
  handlePath,
91
111
  lockPath,
112
+ DAEMON_ROOT_FLAG,
113
+ daemonArgv,
114
+ commandNamesWorkspace,
92
115
  };
@@ -12,6 +12,8 @@ const childProcess = require('child_process');
12
12
 
13
13
  const sessionClient = require('./session-client');
14
14
  const sessionLog = require('./session-log');
15
+ const paths = require('./session-paths');
16
+ const reaper = require('./process-reaper');
15
17
 
16
18
  const DAEMON_BIN = path.join(__dirname, '..', '..', 'bin', 'mauto-session-daemon.js');
17
19
  const DEFAULT_READY_TIMEOUT_MS = 15000;
@@ -31,6 +33,10 @@ async function spawnDaemon({
31
33
  pollMs = DEFAULT_POLL_MS,
32
34
  isAlive = sessionClient.isAlive,
33
35
  openLog = sessionLog.openDaemonLog,
36
+ // Kills a daemon that never became ready (#156). group:true reaches the
37
+ // daemon AND its mobile-mcp engine: `detached: true` below makes the daemon
38
+ // a process-group leader, and the engine is spawned into its group.
39
+ terminate = (pid) => reaper.terminate(pid, { group: true }),
34
40
  } = {}) {
35
41
  if (!projectRoot) throw new TypeError('spawnDaemon requires projectRoot');
36
42
 
@@ -46,7 +52,9 @@ async function spawnDaemon({
46
52
  const log = openLog(projectRoot);
47
53
 
48
54
  try {
49
- const child = spawn(process.execPath, [DAEMON_BIN], {
55
+ // The root in argv is an identity marker only (see paths.daemonArgv): env
56
+ // stays the daemon's source of truth, but `ps` can only see argv.
57
+ const child = spawn(process.execPath, [DAEMON_BIN, ...paths.daemonArgv(projectRoot)], {
50
58
  detached: true,
51
59
  stdio: log.stdio,
52
60
  env,
@@ -80,6 +88,18 @@ async function spawnDaemon({
80
88
  if (await isAlive(projectRoot)) return true;
81
89
  await sleep(pollMs);
82
90
  }
91
+
92
+ // Not ready in time. Without this the child lives on — typically still
93
+ // inside createCall, holding the O_EXCL lock — and every later verb burns
94
+ // its own readiness window against it (#156). Only OUR child, and only if
95
+ // it is still running: an ELOCKED spawn-race loser has already exited and
96
+ // must never signal anything. Node tracked this exact child, so its state
97
+ // needs no pidIdentity check and has no pid-reuse window.
98
+ if (child && child.pid && child.exitCode === null && child.signalCode === null) {
99
+ const outcome = await terminate(child.pid);
100
+ // The parent is the only witness to this kill — write it down (#163).
101
+ log.write(`mauto: daemon not ready after ${readyTimeoutMs}ms; terminated pid=${child.pid} (${outcome})\n`);
102
+ }
83
103
  return false;
84
104
  } finally {
85
105
  // The child holds its own dup, so the parent releases its descriptor rather
@@ -89,4 +109,4 @@ async function spawnDaemon({
89
109
  }
90
110
  }
91
111
 
92
- module.exports = { spawnDaemon, DAEMON_BIN };
112
+ module.exports = { spawnDaemon, DAEMON_BIN, DEFAULT_READY_TIMEOUT_MS };
@@ -112,7 +112,7 @@ Translate user language to action types and `mauto` verbs:
112
112
  | **"allow permission", "grant the permission"** | **`grant_permission`** | **`mauto press grant_permission`** | **Semantic — set optional `permission_name` if specific.** |
113
113
  | **"deny permission", "don't allow"** | **`deny_permission`** | **`mauto press deny_permission`** | **Semantic.** |
114
114
  | "navigate to [tab]" | `tap` | `mauto elements` to find the tab, then `mauto tap --at <x,y>` | |
115
- | "open URL", "navigate to URL" | `open_url` | `mauto press` / open the URL | |
115
+ | "open URL", "open deep link", "navigate to URL" | `open_url` | `mauto open-url <url>` | |
116
116
  | "wait until visible" | `wait_for_element` | Poll `mauto elements` until present | Set `wait_config.type: "element_visible"` |
117
117
  | "wait until gone" | `wait_for_element_gone` | Poll `mauto elements` until absent | |
118
118
  | "wait until loaded", "wait for shimmer to stop" | `wait_for_loading_complete` | Poll `mauto screenshot <path>` + visual check | Set `wait_config.indicator` to match project's loading style |
@@ -241,7 +241,7 @@ Translate user language to `mauto` verbs and schema actions:
241
241
  | "scroll to", "scroll until visible", "scroll down to find" | `scroll_to_element` | `mauto swipe --direction <dir>` (repeated) | Target is the element to scroll to |
242
242
  | "go back", "press back" | `press_button` | `mauto press <button>` | Use the back button |
243
243
  | "navigate to [tab]" | `tap` | `mauto elements` to find the tab, then `mauto tap --at <x,y>` | |
244
- | "open URL", "navigate to URL" | `open_url` | `mauto press` / open the URL | |
244
+ | "open URL", "open deep link", "navigate to URL" | `open_url` | `mauto open-url <url>` | |
245
245
  | "wait until visible", "wait for [element] to appear" | `wait_for_element` | Poll `mauto elements` until present | Set `wait_config.type: "element_visible"` |
246
246
  | "wait until gone", "wait for [element] to disappear" | `wait_for_element_gone` | Poll `mauto elements` until absent | Set `wait_config.type: "element_gone"` |
247
247
  | "wait until loaded", "wait for shimmer to stop", "wait for loading to complete" | `wait_for_loading_complete` | Poll `mauto screenshot <path>` + visual check | Set `wait_config.indicator` to match project's loading style (shimmer/spinner/skeleton) |
@@ -120,10 +120,10 @@ const EVENT_FIELDS = {
120
120
  // src/device/mobile-mcp-tools.js. A daemon-minted counter needs no redaction
121
121
  // argument at all, on exactly the grounds dur_ms is cleared.
122
122
  call_id: sendable(integer({ min: 1 }), 'daemon-minted counter scoped to one session; never the client-supplied frame id'),
123
- stop_reason: sendable(oneOf(STOP_REASONS), 'enumerated: idle|signal|shutdown|crash|explicit'),
123
+ stop_reason: sendable(oneOf(STOP_REASONS), 'enumerated: idle|signal|shutdown|crash|explicit|engine_exit'),
124
124
  // Errno NAMES only: the numeric codes an MCP server reports, and Node's
125
125
  // ERR_* codes, fail the check and are dropped rather than sent.
126
- error_code: sendable(oneOf(ERROR_CODES), 'Node/libuv errno string (EADDRINUSE|EACCES|…) plus our own ELOCKED'),
126
+ error_code: sendable(oneOf(ERROR_CODES), 'Node/libuv errno string (EADDRINUSE|EACCES|…) plus our own ELOCKED|ENGINE_CONNECT_TIMEOUT|ENGINE_EXITED'),
127
127
 
128
128
  // --- crash (slice 4) ---------------------------------------------------
129
129
  // The ONLY new field slice 4 mints. As with `verb`, `tool` and `session_id`,
@@ -43,6 +43,7 @@ const EVENT_NAMES = Object.freeze([
43
43
  'daemon.start_failure',
44
44
  'daemon.listen_failure',
45
45
  'daemon.connect_failure',
46
+ 'daemon.engine_exit',
46
47
  'daemon.lock_conflict',
47
48
  'daemon.undeliverable',
48
49
  // Emitted by the flush path, which lands in a later slice-5 task; declared
@@ -86,11 +87,20 @@ const VERB_NAMES = Object.freeze([
86
87
  ]);
87
88
 
88
89
  // Why the daemon stopped. 'crash' is documented but not yet produced.
89
- const STOP_REASONS = Object.freeze(['idle', 'signal', 'shutdown', 'crash', 'explicit']);
90
+ // 'engine_exit': the mobile-mcp engine died under a serving daemon (#156).
91
+ const STOP_REASONS = Object.freeze(['idle', 'signal', 'shutdown', 'crash', 'explicit', 'engine_exit']);
90
92
 
91
- // Node/libuv errno names for this platform, plus our own ELOCKED (a lock
92
- // conflict, recorded by src/device/session-daemon.js). Names only: the numeric codes an MCP
93
- // server reports are not in this set and so never pass.
94
- const ERROR_CODES = Object.freeze([...Object.keys(os.constants.errno), 'ELOCKED']);
93
+ // Node/libuv errno names for this platform, plus our own codes, all recorded by
94
+ // src/device/: ELOCKED (a lock conflict, session-daemon.js),
95
+ // ENGINE_CONNECT_TIMEOUT (initialize exceeded its bound, mobile-mcp-client.js)
96
+ // and ENGINE_EXITED (the engine died before the daemon was serving,
97
+ // session-daemon.js). Names only: the numeric codes an MCP server reports are
98
+ // not in this set and so never pass.
99
+ const ERROR_CODES = Object.freeze([
100
+ ...Object.keys(os.constants.errno),
101
+ 'ELOCKED',
102
+ 'ENGINE_CONNECT_TIMEOUT',
103
+ 'ENGINE_EXITED',
104
+ ]);
95
105
 
96
106
  module.exports = { OS_NAMES, EVENT_NAMES, VERB_NAMES, STOP_REASONS, ERROR_CODES };