mobile-automator 0.29.0 → 0.30.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/README.md +3 -0
- package/bin/mauto.js +2 -1
- package/package.json +14 -3
- package/src/cli.js +49 -13
- package/src/device/bridge.js +10 -10
- package/src/device/host-support.js +51 -0
- package/src/device/mobile-mcp-client.js +2 -2
- package/src/device/resolve-connection.js +0 -2
- package/src/device/session-daemon.js +40 -6
- package/src/mcp/server.js +5 -2
- package/src/memory/entries.js +1 -0
- package/src/observe/vocab.js +1 -0
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents all have one-
|
|
|
8
8
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](#)
|
|
11
|
+
[](#)
|
|
11
12
|
[](#)
|
|
12
13
|
|
|
13
14
|
---
|
|
@@ -24,6 +25,8 @@ Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents all have one-
|
|
|
24
25
|
npm i -g mobile-automator # exposes `mauto` globally
|
|
25
26
|
```
|
|
26
27
|
|
|
28
|
+
> **Supported hosts:** macOS and Linux. Windows is not supported natively (npm refuses the install) — run `mauto` inside WSL2. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md#-mauto-refuses-to-run-on-windows).
|
|
29
|
+
|
|
27
30
|
<details>
|
|
28
31
|
<summary>Or run it without installing / build from source</summary>
|
|
29
32
|
|
package/bin/mauto.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-automator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"prepublishOnly": "npm test",
|
|
27
27
|
"test:watch": "jest --watch",
|
|
28
28
|
"test:coverage": "jest --coverage",
|
|
29
|
+
"lint": "eslint .",
|
|
29
30
|
"lint:guides": "jest tests/lint",
|
|
30
31
|
"lint:schema-additive": "jest tests/lint/schema-additive.test.js",
|
|
31
32
|
"test:unit": "jest tests/unit",
|
|
@@ -39,11 +40,21 @@
|
|
|
39
40
|
"commander": "^12.1.0"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
43
|
+
"@eslint/js": "^9.39.5",
|
|
44
|
+
"@types/node": "^22.20.5",
|
|
45
|
+
"eslint": "^9.39.5",
|
|
46
|
+
"globals": "^17.13.0",
|
|
42
47
|
"jest": "^29.7.0",
|
|
43
48
|
"jest-environment-jsdom": "^29.7.0",
|
|
44
|
-
"js-yaml": "^4.3.2"
|
|
49
|
+
"js-yaml": "^4.3.2",
|
|
50
|
+
"typescript": "~6.0.3",
|
|
51
|
+
"typescript-eslint": "^8.72.0"
|
|
45
52
|
},
|
|
46
53
|
"engines": {
|
|
47
54
|
"node": ">=22.12.0"
|
|
48
|
-
}
|
|
55
|
+
},
|
|
56
|
+
"os": [
|
|
57
|
+
"darwin",
|
|
58
|
+
"linux"
|
|
59
|
+
]
|
|
49
60
|
}
|
package/src/cli.js
CHANGED
|
@@ -7,7 +7,6 @@ const { Command } = require('commander');
|
|
|
7
7
|
const { version: PKG_VERSION } = require('../package.json');
|
|
8
8
|
|
|
9
9
|
const { ok, fail, render, exitCodeFor } = require('./output/envelope');
|
|
10
|
-
const { DeviceBridge } = require('./device/bridge');
|
|
11
10
|
const selectionStore = require('./device/selection');
|
|
12
11
|
const { engineHint } = require('./device/engine-hints');
|
|
13
12
|
const { ScenarioValidator } = require('./scenario/validator');
|
|
@@ -26,6 +25,7 @@ const guideEmitter = require('./guide/emitter');
|
|
|
26
25
|
const { ADAPTERS } = require('./init/adapters');
|
|
27
26
|
const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
|
|
28
27
|
const connection = require('./device/connection');
|
|
28
|
+
const { isSupportedHost, unsupportedHostError } = require('./device/host-support');
|
|
29
29
|
const { record } = require('./observe/recorder');
|
|
30
30
|
const { resolveRunId } = require('./observe/settings');
|
|
31
31
|
const { runTracePath } = require('./observe/paths');
|
|
@@ -267,6 +267,19 @@ function handleValidate({ validator }, file, opts = {}) {
|
|
|
267
267
|
return { envelope: env, exitKind: 'invalid_input' };
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
// The unsupported-host gate (#165), shared by every site that reaches the
|
|
271
|
+
// session layer: connectBridge (all device verbs) and the `session` verbs,
|
|
272
|
+
// which talk to connection.* directly. Null on a supported host; otherwise an
|
|
273
|
+
// `environment` failure (exit 5). Deliberately NOT routed through deviceFail —
|
|
274
|
+
// a `device` kind is exactly the misdiagnosis this exists to prevent: on
|
|
275
|
+
// Windows the session layer's Unix socket, `ps` and process-group kill fail in
|
|
276
|
+
// ways that read as "no device connected".
|
|
277
|
+
function hostGate(platform) {
|
|
278
|
+
if (isSupportedHost(platform)) return null;
|
|
279
|
+
const err = unsupportedHostError(platform);
|
|
280
|
+
return { envelope: fail(err.kind, err.message, err.hint), exitKind: err.kind };
|
|
281
|
+
}
|
|
282
|
+
|
|
270
283
|
function deviceFail(err) {
|
|
271
284
|
// Respect a typed error's kind (e.g. the daemon's 'timeout') so the envelope
|
|
272
285
|
// and exit code reflect the real failure class; anything else defaults to
|
|
@@ -1196,7 +1209,7 @@ async function handleSessionStart(
|
|
|
1196
1209
|
projectRoot,
|
|
1197
1210
|
spawn = require('./device/session-spawn'),
|
|
1198
1211
|
client = require('./device/session-client'),
|
|
1199
|
-
readHandle = require('./device/resolve-connection').readHandleDevice,
|
|
1212
|
+
readHandle: readHandleDevice = require('./device/resolve-connection').readHandleDevice,
|
|
1200
1213
|
},
|
|
1201
1214
|
opts = {}
|
|
1202
1215
|
) {
|
|
@@ -1219,7 +1232,7 @@ async function handleSessionStart(
|
|
|
1219
1232
|
// pinned to A reported already_running:true, device:B while every verb
|
|
1220
1233
|
// kept driving A. Fail with a hint instead — the agent stays in control
|
|
1221
1234
|
// and decides whether to `session end` and repin.
|
|
1222
|
-
const pinned =
|
|
1235
|
+
const pinned = readHandleDevice(projectRoot);
|
|
1223
1236
|
if (device !== null && device !== pinned) {
|
|
1224
1237
|
return {
|
|
1225
1238
|
envelope: fail(
|
|
@@ -1377,7 +1390,7 @@ async function handleDevicesClear({
|
|
|
1377
1390
|
store = selectionStore,
|
|
1378
1391
|
projectRoot,
|
|
1379
1392
|
isAlive = require('./device/connection').isSessionAlive,
|
|
1380
|
-
readHandle = require('./device/resolve-connection').readHandleDevice,
|
|
1393
|
+
readHandle: readHandleDevice = require('./device/resolve-connection').readHandleDevice,
|
|
1381
1394
|
} = {}) {
|
|
1382
1395
|
const previous = store.read(projectRoot);
|
|
1383
1396
|
store.clear(projectRoot);
|
|
@@ -1389,7 +1402,7 @@ async function handleDevicesClear({
|
|
|
1389
1402
|
if (!alive) {
|
|
1390
1403
|
return { envelope: ok({ cleared: previous || null }), exitKind: 'ok' };
|
|
1391
1404
|
}
|
|
1392
|
-
const pinned =
|
|
1405
|
+
const pinned = readHandleDevice(projectRoot);
|
|
1393
1406
|
return {
|
|
1394
1407
|
envelope: ok(
|
|
1395
1408
|
{ cleared: previous || null, daemon_still_pinned: pinned },
|
|
@@ -1576,6 +1589,8 @@ function buildProgram(deps = {}) {
|
|
|
1576
1589
|
emitters = makeEmitters({ projectRoot }),
|
|
1577
1590
|
// Sink used to emit the rendered envelope + drive the exit code.
|
|
1578
1591
|
emit = emitters.emit,
|
|
1592
|
+
// Host OS, for the unsupported-host gate (#165). Overridable in tests.
|
|
1593
|
+
platform = process.platform,
|
|
1579
1594
|
} = deps;
|
|
1580
1595
|
|
|
1581
1596
|
const program = new Command();
|
|
@@ -1672,6 +1687,18 @@ function buildProgram(deps = {}) {
|
|
|
1672
1687
|
return action(...args);
|
|
1673
1688
|
};
|
|
1674
1689
|
|
|
1690
|
+
// Gate for verbs that reach the device session layer (#165). Runs before
|
|
1691
|
+
// anything is spawned or connected; see hostGate. Emits and returns true when
|
|
1692
|
+
// the host is unsupported, so a caller can bail out.
|
|
1693
|
+
const refuseUnsupportedHost = () => {
|
|
1694
|
+
const refusal = hostGate(platform);
|
|
1695
|
+
if (!refusal) return false;
|
|
1696
|
+
emit(refusal, humanFlag());
|
|
1697
|
+
return true;
|
|
1698
|
+
};
|
|
1699
|
+
const requireSupportedHost = (action) => (...args) =>
|
|
1700
|
+
(refuseUnsupportedHost() ? undefined : action(...args));
|
|
1701
|
+
|
|
1675
1702
|
// Resolve which device a verb targets: explicit --device wins, else the
|
|
1676
1703
|
// persisted selection, else null (mobile-mcp auto-selects a single device).
|
|
1677
1704
|
// Keeping the no-selection fast path returning null preserves zero-config use.
|
|
@@ -1687,6 +1714,9 @@ function buildProgram(deps = {}) {
|
|
|
1687
1714
|
// (the single connection seam), so this keeps both the envelope boundary and
|
|
1688
1715
|
// the acquire-seam refactor.
|
|
1689
1716
|
const connectBridge = async (device, fn) => {
|
|
1717
|
+
// Before the factory: on an unsupported host there is nothing to connect,
|
|
1718
|
+
// and a connect failure would land below as a misleading `device` kind.
|
|
1719
|
+
if (refuseUnsupportedHost()) return;
|
|
1690
1720
|
let bridge;
|
|
1691
1721
|
let close;
|
|
1692
1722
|
try {
|
|
@@ -2106,26 +2136,26 @@ function buildProgram(deps = {}) {
|
|
|
2106
2136
|
.description('Start the device session daemon (subsequent verbs reuse its connection)')
|
|
2107
2137
|
.option('--device <id>', 'pin the daemon to a target device id')
|
|
2108
2138
|
.option('--idle <ms>', 'idle timeout in milliseconds before the daemon self-reaps')
|
|
2109
|
-
.action(withEnvelope(requireWorkspace(async (opts) => {
|
|
2139
|
+
.action(withEnvelope(requireSupportedHost(requireWorkspace(async (opts) => {
|
|
2110
2140
|
const r = await handleSessionStart({ projectRoot }, { device: opts.device, idle: opts.idle });
|
|
2111
2141
|
emit(r, humanFlag());
|
|
2112
|
-
})));
|
|
2142
|
+
}))));
|
|
2113
2143
|
|
|
2114
2144
|
session
|
|
2115
2145
|
.command('status')
|
|
2116
2146
|
.description('Report whether a device session daemon is running')
|
|
2117
|
-
.action(withEnvelope(async () => {
|
|
2147
|
+
.action(withEnvelope(requireSupportedHost(async () => {
|
|
2118
2148
|
const r = await handleSessionStatus({ projectRoot });
|
|
2119
2149
|
emit(r, humanFlag());
|
|
2120
|
-
}));
|
|
2150
|
+
})));
|
|
2121
2151
|
|
|
2122
2152
|
session
|
|
2123
2153
|
.command('end')
|
|
2124
2154
|
.description('Stop the device session daemon and remove its socket/pidfile')
|
|
2125
|
-
.action(withEnvelope(async () => {
|
|
2155
|
+
.action(withEnvelope(requireSupportedHost(async () => {
|
|
2126
2156
|
const r = await handleSessionEnd({ projectRoot });
|
|
2127
2157
|
emit(r, humanFlag());
|
|
2128
|
-
}));
|
|
2158
|
+
})));
|
|
2129
2159
|
|
|
2130
2160
|
// --- Issue #92: device discovery + persisted selection -------------------
|
|
2131
2161
|
|
|
@@ -2150,6 +2180,12 @@ function buildProgram(deps = {}) {
|
|
|
2150
2180
|
devices
|
|
2151
2181
|
.command('clear')
|
|
2152
2182
|
.description('Remove the persisted device selection')
|
|
2183
|
+
// Deliberately OUTSIDE the host gate (#165): its only session-layer probe,
|
|
2184
|
+
// sessionClient.isAlive, is a socket ping — it never runs `ps` or signals a
|
|
2185
|
+
// process, so it answers safely on an unsupported host too. That invariant
|
|
2186
|
+
// is executable, not just prose: the canary in
|
|
2187
|
+
// tests/unit/cli-host-support.test.js ("devices clear stays process-free")
|
|
2188
|
+
// fails if this verb ever starts spawning child processes.
|
|
2153
2189
|
.action(withEnvelope(async () => {
|
|
2154
2190
|
const r = await handleDevicesClear({ projectRoot });
|
|
2155
2191
|
emit(r, humanFlag());
|
|
@@ -2288,7 +2324,7 @@ function makeEmitters({ projectRoot = process.cwd() } = {}) {
|
|
|
2288
2324
|
// needed). A Date.now() stamped at require time would measure from whenever
|
|
2289
2325
|
// cli.js was loaded, which is not process start for anything that requires it
|
|
2290
2326
|
// first (a test harness, bin/mauto.js's own guards).
|
|
2291
|
-
function finish({ text = '', exitKind, ok, errorKind, exitCode = exitCodeFor(exitKind) }) {
|
|
2327
|
+
function finish({ text = '', exitKind, ok: succeeded, errorKind, exitCode = exitCodeFor(exitKind) }) {
|
|
2292
2328
|
// The flag-resolved id when a command parsed, the environment otherwise: a
|
|
2293
2329
|
// parse failure reaches no preAction hook and so has no flag, but it is
|
|
2294
2330
|
// still part of the run and is exactly the class of failure (#146) this
|
|
@@ -2318,7 +2354,7 @@ function makeEmitters({ projectRoot = process.cwd() } = {}) {
|
|
|
2318
2354
|
// tying this line to a run. sends:false in the event catalog.
|
|
2319
2355
|
run_id: runId || undefined,
|
|
2320
2356
|
session_id: resolvedSessionId || undefined,
|
|
2321
|
-
ok,
|
|
2357
|
+
ok: succeeded,
|
|
2322
2358
|
error_kind: errorKind,
|
|
2323
2359
|
exit_code: exitCode,
|
|
2324
2360
|
dur_ms: Math.round(process.uptime() * 1000),
|
package/src/device/bridge.js
CHANGED
|
@@ -44,7 +44,7 @@ class DeviceBridge {
|
|
|
44
44
|
// Tap at absolute screen coordinates. The agent derives x,y from listElements
|
|
45
45
|
// centers, so the bridge stays free of any element targeting logic.
|
|
46
46
|
async tap({ x, y } = {}) {
|
|
47
|
-
return this._call('mobile_click_on_screen_at_coordinates', { x, y });
|
|
47
|
+
return await this._call('mobile_click_on_screen_at_coordinates', { x, y });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// Long-press at absolute screen coordinates. Optional duration (ms) overrides
|
|
@@ -52,18 +52,18 @@ class DeviceBridge {
|
|
|
52
52
|
async longPress({ x, y, duration } = {}) {
|
|
53
53
|
const args = { x, y };
|
|
54
54
|
if (duration !== undefined) args.duration = duration;
|
|
55
|
-
return this._call('mobile_long_press_on_screen_at_coordinates', args);
|
|
55
|
+
return await this._call('mobile_long_press_on_screen_at_coordinates', args);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Double-tap at absolute screen coordinates.
|
|
59
59
|
async doubleTap({ x, y } = {}) {
|
|
60
|
-
return this._call('mobile_double_tap_on_screen', { x, y });
|
|
60
|
+
return await this._call('mobile_double_tap_on_screen', { x, y });
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// Type into the focused element. We never auto-submit; pressing ENTER is a
|
|
64
64
|
// distinct, explicit action via pressButton.
|
|
65
65
|
async type(text) {
|
|
66
|
-
return this._call('mobile_type_keys', { text, submit: false });
|
|
66
|
+
return await this._call('mobile_type_keys', { text, submit: false });
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// Swipe in a cardinal direction.
|
|
@@ -135,32 +135,32 @@ class DeviceBridge {
|
|
|
135
135
|
|
|
136
136
|
// Press a hardware/system button (BACK, HOME, ENTER, ...).
|
|
137
137
|
async pressButton(button) {
|
|
138
|
-
return this._call('mobile_press_button', { button });
|
|
138
|
+
return await this._call('mobile_press_button', { button });
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// Launch an installed app by its package/bundle identifier.
|
|
142
142
|
async launchApp(packageName) {
|
|
143
|
-
return this._call('mobile_launch_app', { packageName });
|
|
143
|
+
return await this._call('mobile_launch_app', { packageName });
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// Install an app from a local artifact (apk/ipa) path.
|
|
147
147
|
async installApp(appPath) {
|
|
148
|
-
return this._call('mobile_install_app', { path: appPath });
|
|
148
|
+
return await this._call('mobile_install_app', { path: appPath });
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// Uninstall an app by its bundle/package identifier.
|
|
152
152
|
async uninstallApp(appId) {
|
|
153
|
-
return this._call('mobile_uninstall_app', { bundle_id: appId });
|
|
153
|
+
return await this._call('mobile_uninstall_app', { bundle_id: appId });
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
// Open a URL (deep link or web) on the device.
|
|
157
157
|
async openUrl(url) {
|
|
158
|
-
return this._call('mobile_open_url', { url });
|
|
158
|
+
return await this._call('mobile_open_url', { url });
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
// Set the device orientation ('portrait'/'landscape').
|
|
162
162
|
async setOrientation(orientation) {
|
|
163
|
-
return this._call('mobile_set_orientation', { orientation });
|
|
163
|
+
return await this._call('mobile_set_orientation', { orientation });
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
// Crash reports currently readable on the device, as the agnostic crash
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Which hosts can run the device session, and the error for the ones that
|
|
4
|
+
// cannot (#165). The session daemon binds a Unix domain socket, and the
|
|
5
|
+
// session layer identifies and reaps its daemon with `ps`, process-group kill
|
|
6
|
+
// (`kill(-pid)`) and POSIX signals. None of that exists on Windows, where it
|
|
7
|
+
// used to fail as a confusing `device` error — as if no phone were connected —
|
|
8
|
+
// rather than saying the host itself is unsupported.
|
|
9
|
+
//
|
|
10
|
+
// package.json `os` is the single source of truth: npm refuses the install on
|
|
11
|
+
// any other host, and this guard catches what slips past it (`--force`,
|
|
12
|
+
// `npm link`, a vendored copy). Pure — no I/O; the caller decides how to emit.
|
|
13
|
+
|
|
14
|
+
const pkg = require('../../package.json');
|
|
15
|
+
|
|
16
|
+
const SUPPORTED_HOSTS = Object.freeze([...(pkg.os || [])]);
|
|
17
|
+
|
|
18
|
+
// Human names for the error text — the ONE copy; the docs-agreement lint test
|
|
19
|
+
// (tests/lint/host-support-agreement.test.js) consumes it rather than
|
|
20
|
+
// restating it. Deliberately no fallback: a host added to package.json `os`
|
|
21
|
+
// without a name here is a test failure and a crash at render time, not a raw
|
|
22
|
+
// platform id leaking into the user-facing error.
|
|
23
|
+
const HOST_NAMES = Object.freeze({ darwin: 'macOS', linux: 'Linux' });
|
|
24
|
+
|
|
25
|
+
const TROUBLESHOOTING_REF = 'see TROUBLESHOOTING.md ("mauto refuses to run on Windows")';
|
|
26
|
+
|
|
27
|
+
// Per-host guidance. Unknown hosts get the generic pointer instead of a
|
|
28
|
+
// Windows-specific instruction — a freebsd user must not be told about WSL2.
|
|
29
|
+
const HOST_HINTS = Object.freeze({
|
|
30
|
+
win32: `On Windows, install and run mauto inside WSL2 (a Linux distribution); ${TROUBLESHOOTING_REF}.`,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// For hosts with no specific hint: point at the troubleshooting entry without
|
|
34
|
+
// naming an OS-specific route.
|
|
35
|
+
const GENERIC_HINT = `See TROUBLESHOOTING.md for where mauto runs and how to get there.`;
|
|
36
|
+
|
|
37
|
+
function isSupportedHost(platform = process.platform) {
|
|
38
|
+
return SUPPORTED_HOSTS.includes(platform);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function unsupportedHostError(platform = process.platform) {
|
|
42
|
+
const names = SUPPORTED_HOSTS.map((p) => HOST_NAMES[p]).join(' or ');
|
|
43
|
+
const err = new Error(
|
|
44
|
+
`mauto's device session needs a POSIX host (${names}); "${platform}" is not supported`
|
|
45
|
+
);
|
|
46
|
+
err.kind = 'environment';
|
|
47
|
+
err.hint = HOST_HINTS[platform] || GENERIC_HINT;
|
|
48
|
+
return err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { SUPPORTED_HOSTS, HOST_NAMES, isSupportedHost, unsupportedHostError };
|
|
@@ -133,8 +133,8 @@ async function createCall({
|
|
|
133
133
|
throw connectError(err, connectTimeoutMs);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
async function rawCall(toolName,
|
|
137
|
-
const res = await client.callTool({ name: toolName, arguments:
|
|
136
|
+
async function rawCall(toolName, toolArgs = {}) {
|
|
137
|
+
const res = await client.callTool({ name: toolName, arguments: toolArgs });
|
|
138
138
|
return parseToolResult(res);
|
|
139
139
|
}
|
|
140
140
|
const { call } = makeCall({ rawCall, device });
|
|
@@ -31,8 +31,6 @@ function defaultCreateCall(opts) {
|
|
|
31
31
|
return require('./mobile-mcp-client').createCall(opts);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const NOOP_CLOSE = () => Promise.resolve();
|
|
35
|
-
|
|
36
34
|
// Read the live daemon's pinned device from its handle, or null. A null pin
|
|
37
35
|
// means the daemon serves whatever device mobile-mcp selected (matches any
|
|
38
36
|
// request that doesn't pin a specific device).
|
|
@@ -386,7 +386,13 @@ async function startDaemon({
|
|
|
386
386
|
clearFlush();
|
|
387
387
|
if (!flush || stopping) return;
|
|
388
388
|
if (!(delayMs > 0) || !Number.isFinite(delayMs)) return;
|
|
389
|
-
|
|
389
|
+
// tick() never rejects (its flush() is wrapped), so `void` is honest here.
|
|
390
|
+
flushTimer = setTimeout(() => {
|
|
391
|
+
void tick();
|
|
392
|
+
}, delayMs);
|
|
393
|
+
if (typeof flushTimer.unref === 'function') flushTimer.unref();
|
|
394
|
+
|
|
395
|
+
async function tick() {
|
|
390
396
|
if (stopping) return;
|
|
391
397
|
if (inFlight > 0) {
|
|
392
398
|
armFlush(delayMs);
|
|
@@ -402,8 +408,7 @@ async function startDaemon({
|
|
|
402
408
|
// current caller.
|
|
403
409
|
}
|
|
404
410
|
armFlush(next);
|
|
405
|
-
}
|
|
406
|
-
if (typeof flushTimer.unref === 'function') flushTimer.unref();
|
|
411
|
+
}
|
|
407
412
|
}
|
|
408
413
|
|
|
409
414
|
async function stop(reason = 'explicit') {
|
|
@@ -541,7 +546,27 @@ async function startDaemon({
|
|
|
541
546
|
return true;
|
|
542
547
|
};
|
|
543
548
|
|
|
544
|
-
socket.on('data', async
|
|
549
|
+
// A plain listener around an async body, never `socket.on('data', async …)`:
|
|
550
|
+
// the emitter ignores the returned promise, so a throw in the body would be
|
|
551
|
+
// an unhandled rejection, and bin/mauto-session-daemon.js exits on those.
|
|
552
|
+
// Found by @typescript-eslint/no-misused-promises (#164).
|
|
553
|
+
socket.on('data', (chunk) => {
|
|
554
|
+
handleData(chunk).catch(onHandlerError);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Recorded, then the idle clock re-armed: handleData paused it for the
|
|
558
|
+
// frame that threw, and nothing else would restart it. The remaining frames
|
|
559
|
+
// of that chunk are lost; the socket and the daemon both keep serving.
|
|
560
|
+
function onHandlerError(err) {
|
|
561
|
+
safeObserve({
|
|
562
|
+
level: 'error',
|
|
563
|
+
event: 'daemon.handler_error',
|
|
564
|
+
message: err && (err.message || String(err)),
|
|
565
|
+
});
|
|
566
|
+
armIdle();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function handleData(chunk) {
|
|
545
570
|
const frames = parser.push(chunk);
|
|
546
571
|
for (const f of frames) {
|
|
547
572
|
// Pause the idle clock while we handle a frame so it can never fire
|
|
@@ -554,11 +579,20 @@ async function startDaemon({
|
|
|
554
579
|
continue;
|
|
555
580
|
}
|
|
556
581
|
const req = f.value;
|
|
582
|
+
// Valid JSON is not yet a request: `null` would throw on `req.type`
|
|
583
|
+
// below, and a primitive or array is no frame the protocol defines.
|
|
584
|
+
if (req === null || typeof req !== 'object' || Array.isArray(req)) {
|
|
585
|
+
await reply({ id: null, ok: false, error: { message: 'malformed frame: not a JSON object' } });
|
|
586
|
+
armIdle();
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
557
589
|
|
|
558
590
|
if (req.type === 'shutdown') {
|
|
559
591
|
await reply({ id: req.id, ok: true, result: { stopping: true } });
|
|
560
592
|
// Reply already flushed (awaited) — now tear down.
|
|
561
|
-
setImmediate(() =>
|
|
593
|
+
setImmediate(() => {
|
|
594
|
+
stop('shutdown').catch(() => {});
|
|
595
|
+
});
|
|
562
596
|
continue;
|
|
563
597
|
}
|
|
564
598
|
if (req.type === 'ping') {
|
|
@@ -616,7 +650,7 @@ async function startDaemon({
|
|
|
616
650
|
await reply({ id: req.id, ok: false, error: { message: `unknown request type: ${req.type}` } });
|
|
617
651
|
armIdle();
|
|
618
652
|
}
|
|
619
|
-
}
|
|
653
|
+
}
|
|
620
654
|
|
|
621
655
|
socket.on('error', () => {
|
|
622
656
|
sockets.delete(socket);
|
package/src/mcp/server.js
CHANGED
|
@@ -31,11 +31,14 @@ function buildServer({ projectRoot } = {}) {
|
|
|
31
31
|
{ capabilities: { prompts: {} } }
|
|
32
32
|
);
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Plain functions, not async: both handlers are synchronous, and the SDK
|
|
35
|
+
// accepts a result or a promise of one. It calls them inside a .then(), so a
|
|
36
|
+
// throw (an unknown prompt name) still becomes an MCP error reply.
|
|
37
|
+
server.setRequestHandler(ListPromptsRequestSchema, () => ({
|
|
35
38
|
prompts: handlers.listPrompts(),
|
|
36
39
|
}));
|
|
37
40
|
|
|
38
|
-
server.setRequestHandler(GetPromptRequestSchema,
|
|
41
|
+
server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
|
39
42
|
return handlers.getPrompt(request.params.name);
|
|
40
43
|
});
|
|
41
44
|
|
package/src/memory/entries.js
CHANGED
|
@@ -12,6 +12,7 @@ const ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\[asserted\]\s+(.*)$/;
|
|
|
12
12
|
|
|
13
13
|
function sanitizeEntryText(raw) {
|
|
14
14
|
return String(raw == null ? '' : raw)
|
|
15
|
+
// eslint-disable-next-line no-control-regex -- matching control chars is the point
|
|
15
16
|
.replace(/[\u0000-\u001F\u007F]+/g, ' ') // control chars / newlines / tabs -> space
|
|
16
17
|
.replace(/\s+/g, ' ')
|
|
17
18
|
.trim();
|
package/src/observe/vocab.js
CHANGED
|
@@ -46,6 +46,7 @@ const EVENT_NAMES = Object.freeze([
|
|
|
46
46
|
'daemon.engine_exit',
|
|
47
47
|
'daemon.lock_conflict',
|
|
48
48
|
'daemon.undeliverable',
|
|
49
|
+
'daemon.handler_error',
|
|
49
50
|
// Emitted by the flush path, which lands in a later slice-5 task; declared
|
|
50
51
|
// now because its event shape is already exercised by unit tests.
|
|
51
52
|
'telemetry.flush',
|