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 CHANGED
@@ -8,6 +8,7 @@ Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents all have one-
8
8
 
9
9
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
10
10
  [![Platform](https://img.shields.io/badge/Platform-Android%20%7C%20iOS%20%7C%20Flutter%20%7C%20RN%20%7C%20KMP%20%7C%20CMP-green.svg)](#)
11
+ [![Host](https://img.shields.io/badge/Host-macOS%20%7C%20Linux-lightgrey.svg)](#)
11
12
  [![Node](https://img.shields.io/badge/Node-%E2%89%A522.12-brightgreen.svg)](#)
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
@@ -10,4 +10,5 @@ const { run, emitFatal } = require('../src/cli');
10
10
  // exit-on-reject listener.
11
11
  process.on('unhandledRejection', emitFatal);
12
12
 
13
- run(process.argv);
13
+ // run() catches everything into emitFatal itself, so the promise never rejects.
14
+ void run(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-automator",
3
- "version": "0.29.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 = readHandle(projectRoot);
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 = readHandle(projectRoot);
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),
@@ -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, args = {}) {
137
- const res = await client.callTool({ name: toolName, arguments: args });
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
- flushTimer = setTimeout(async () => {
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
- }, delayMs);
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 (chunk) => {
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(() => stop('shutdown').catch(() => {}));
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
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({
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, async (request) => {
41
+ server.setRequestHandler(GetPromptRequestSchema, (request) => {
39
42
  return handlers.getPrompt(request.params.name);
40
43
  });
41
44
 
@@ -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();
@@ -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',