@mobileaidev/ai-app-bridge 0.2.4 → 0.2.6

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
@@ -3,9 +3,10 @@
3
3
  ```bash
4
4
  npm install -g @mobileaidev/ai-app-bridge
5
5
 
6
- ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
6
+ ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
7
7
  ai-app-bridge tree --package-name io.github.mobileaidev.aiappbridge.sample
8
8
  ai-app-bridge install-apk --package-name io.github.mobileaidev.aiappbridge.sample --apk-path app-debug.apk
9
+ ai-app-bridge clear-app-data --package-name io.github.mobileaidev.aiappbridge.sample
9
10
  ai-app-bridge launch-app --package-name io.github.mobileaidev.aiappbridge.sample
10
11
  ai-app-bridge launch-activity --package-name io.github.mobileaidev.aiappbridge.sample --activity .MainActivity --extra route=/home
11
12
  ai-app-bridge screenshot --package-name io.github.mobileaidev.aiappbridge.sample
@@ -21,12 +22,38 @@ into the model context:
21
22
  - `capabilities` lists the bridge domains and command names.
22
23
  - `run` executes a selected command with command-specific arguments.
23
24
 
24
- This keeps install, launch, UI, Flutter, WebView, logcat, network, and
25
- permission capabilities discoverable without exposing dozens of full schemas at
26
- session start. Set `AI_APP_BRIDGE_MCP_SURFACE=full` before launching
25
+ This keeps install, data reset, launch, UI, Flutter, WebView, logcat, network,
26
+ and permission capabilities discoverable without exposing dozens of full schemas
27
+ at session start. Set `AI_APP_BRIDGE_MCP_SURFACE=full` before launching
27
28
  `ai-app-bridge-mcp` only when a client needs the legacy one-tool-per-command
28
29
  surface.
29
30
 
31
+ The MCP server accepts both standard `Content-Length` framed JSON-RPC messages
32
+ and single-line JSON messages. Responses use the format of the first request on
33
+ that connection, so standard MCP clients keep framed responses while local
34
+ Node REPL scripts can send and read one JSON object per line.
35
+
36
+ For multi-step app automation, call `run` with `command: "batch"`. Batch steps
37
+ run serially in one MCP call, so a failed step can stop and mark the remaining
38
+ steps as skipped without mixing results from different commands:
39
+
40
+ ```json
41
+ {
42
+ "command": "batch",
43
+ "arguments": {
44
+ "defaults": {
45
+ "packageName": "io.github.mobileaidev.aiappbridge.sample"
46
+ },
47
+ "steps": [
48
+ { "id": "launch", "command": "launch-app" },
49
+ { "id": "wait-home", "command": "wait-text", "arguments": { "targetText": "Home" } },
50
+ { "id": "capture-logs", "command": "logs", "arguments": { "limit": 20 } }
51
+ ],
52
+ "stopOnError": true
53
+ }
54
+ }
55
+ ```
56
+
30
57
  WebView network and console capture use Android WebView DevTools/CDP when the
31
58
  target app is debuggable and WebView debugging is enabled.
32
59
 
@@ -70,6 +70,7 @@ Device/action commands:
70
70
 
71
71
  App/permission commands:
72
72
  install-apk Install an APK and assist device-side installer screens.
73
+ clear-app-data Clear target app local data through the bridge runtime.
73
74
  launch-app Launch the target package LAUNCHER Activity.
74
75
  launch-activity Launch an explicit Android Activity component.
75
76
  launch-native-test Launch the debug native Android bridge test Activity.
@@ -296,10 +297,12 @@ async function runCommand(command, options, ctx) {
296
297
  return keyboardState(ctx);
297
298
  case 'hide-keyboard':
298
299
  return hideKeyboard(ctx, options);
299
- case 'install-apk':
300
- return installApk(ctx, options);
301
- case 'webview-pages':
302
- return webviewPages(ctx, options);
300
+ case 'install-apk':
301
+ return installApk(ctx, options);
302
+ case 'clear-app-data':
303
+ return clearAppData(ctx);
304
+ case 'webview-pages':
305
+ return webviewPages(ctx, options);
303
306
  case 'webview-network':
304
307
  return webviewCdpCapture(ctx, { ...options, captureNetwork: true, captureConsole: true });
305
308
  case 'webview-console':
@@ -976,6 +979,13 @@ function normalizeBridgeError(error) {
976
979
  suggestion: 'Check the device state and retry; if the bridge port is known, pass --port to skip package port discovery.',
977
980
  };
978
981
  }
982
+ if (error?.aiAppBridgePackageMismatch || lower.includes('bridge package mismatch')) {
983
+ return {
984
+ code: 'bridge_package_mismatch',
985
+ message,
986
+ suggestion: 'The resolved bridge port belongs to another package. Relaunch the target app and retry with the explicit packageName.',
987
+ };
988
+ }
979
989
  if (error?.aiAppBridgePortDiscovery || lower.includes('bridge port discovery failed') || lower.includes('run-as') || lower.includes('package not found')) {
980
990
  return {
981
991
  code: 'bridge_port_discovery_failed',
@@ -1001,21 +1011,38 @@ function firstErrorLine(error) {
1001
1011
  return String(error?.message || error || 'unknown_error').split(/\r?\n/).find(Boolean) || 'unknown_error';
1002
1012
  }
1003
1013
 
1004
- async function bridgeGet(ctx, requestPath) {
1005
- await ensureForward(ctx);
1006
- const body = await httpGet(bridgeUrl(ctx, requestPath));
1007
- return JSON.parse(body);
1008
- }
1009
-
1010
- async function bridgePost(ctx, requestPath, payload) {
1011
- await ensureForward(ctx);
1012
- const body = await httpPost(bridgeUrl(ctx, requestPath), payload);
1013
- return JSON.parse(body);
1014
- }
1015
-
1016
- function bridgeUrl(ctx, requestPath) {
1017
- return `http://127.0.0.1:${ctx.hostPort || ctx.port}${requestPath}`;
1018
- }
1014
+ async function bridgeGet(ctx, requestPath) {
1015
+ await ensureForward(ctx);
1016
+ const body = await httpGet(bridgeUrl(ctx, requestPath));
1017
+ const payload = JSON.parse(body);
1018
+ verifyBridgeTargetPackage(ctx, payload, requestPath);
1019
+ return payload;
1020
+ }
1021
+
1022
+ async function bridgePost(ctx, requestPath, payload) {
1023
+ await ensureForward(ctx);
1024
+ const body = await httpPost(bridgeUrl(ctx, requestPath), payload);
1025
+ const responsePayload = JSON.parse(body);
1026
+ verifyBridgeTargetPackage(ctx, responsePayload, requestPath);
1027
+ return responsePayload;
1028
+ }
1029
+
1030
+ function bridgeUrl(ctx, requestPath) {
1031
+ return `http://127.0.0.1:${ctx.hostPort || ctx.port}${requestPath}`;
1032
+ }
1033
+
1034
+ function verifyBridgeTargetPackage(ctx, payload, requestPath) {
1035
+ if (!ctx.explicitPackageName || !payload || typeof payload !== 'object') return;
1036
+ const responsePackageName = payload.app && typeof payload.app === 'object'
1037
+ ? payload.app.packageName
1038
+ : undefined;
1039
+ if (!responsePackageName || responsePackageName === ctx.packageName) return;
1040
+ const error = new Error(`bridge package mismatch for ${requestPath}: expected ${ctx.packageName}, got ${responsePackageName}`);
1041
+ error.aiAppBridgePackageMismatch = true;
1042
+ error.expectedPackageName = ctx.packageName;
1043
+ error.actualPackageName = responsePackageName;
1044
+ throw error;
1045
+ }
1019
1046
 
1020
1047
  function httpGet(url) {
1021
1048
  return new Promise((resolve, reject) => {
@@ -3676,17 +3703,70 @@ async function safePermissionState(ctx, permission) {
3676
3703
  }
3677
3704
  }
3678
3705
 
3679
- async function appopsSet(ctx, op, mode) {
3680
- await adb(ctx, ['shell', 'appops', 'set', ctx.packageName, op, mode]);
3681
- return {
3706
+ async function appopsSet(ctx, op, mode) {
3707
+ await adb(ctx, ['shell', 'appops', 'set', ctx.packageName, op, mode]);
3708
+ return {
3682
3709
  ok: true,
3683
3710
  packageName: ctx.packageName,
3684
3711
  op,
3685
3712
  mode,
3686
- };
3687
- }
3688
-
3689
- async function resolveLogcatPid(ctx, options) {
3713
+ };
3714
+ }
3715
+
3716
+ async function clearAppData(ctx) {
3717
+ if (!ctx.explicitPackageName) {
3718
+ throw new Error('packageName is required for clear-app-data');
3719
+ }
3720
+ let bridgeError = null;
3721
+ try {
3722
+ const bridgeResult = await bridgePost(ctx, '/v1/app/clear-data', {});
3723
+ return {
3724
+ ...bridgeResult,
3725
+ packageName: ctx.packageName,
3726
+ method: 'bridge-runtime',
3727
+ };
3728
+ } catch (error) {
3729
+ bridgeError = firstErrorLine(error);
3730
+ }
3731
+
3732
+ let result;
3733
+ try {
3734
+ result = await adb(ctx, clearAppDataAdbArgs(ctx.packageName));
3735
+ } catch (error) {
3736
+ return {
3737
+ ok: false,
3738
+ packageName: ctx.packageName,
3739
+ action: 'clear-app-data',
3740
+ error: 'clear_app_data_failed',
3741
+ bridgeError,
3742
+ message: error.message || String(error),
3743
+ };
3744
+ }
3745
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
3746
+ if (!/^Success\b/im.test(output)) {
3747
+ return {
3748
+ ok: false,
3749
+ packageName: ctx.packageName,
3750
+ error: 'clear_app_data_failed',
3751
+ bridgeError,
3752
+ output,
3753
+ };
3754
+ }
3755
+ return {
3756
+ ok: true,
3757
+ packageName: ctx.packageName,
3758
+ action: 'clear-app-data',
3759
+ method: 'pm-clear',
3760
+ bridgeError,
3761
+ output,
3762
+ };
3763
+ }
3764
+
3765
+ function clearAppDataAdbArgs(packageName) {
3766
+ return ['shell', 'pm', 'clear', packageName];
3767
+ }
3768
+
3769
+ async function resolveLogcatPid(ctx, options) {
3690
3770
  if (options.pid && options.pid !== true && options.pid !== 'current') {
3691
3771
  return String(options.pid);
3692
3772
  }
@@ -4279,8 +4359,9 @@ function requiredNumber(value, name) {
4279
4359
  }
4280
4360
 
4281
4361
  module.exports = {
4282
- buildBridgeFailureResult,
4283
- defaultInstallerButtonTexts,
4362
+ buildBridgeFailureResult,
4363
+ clearAppDataAdbArgs,
4364
+ defaultInstallerButtonTexts,
4284
4365
  artifactTimestamp,
4285
4366
  compactBridgeTree,
4286
4367
  compactStatus,
@@ -4311,16 +4392,17 @@ module.exports = {
4311
4392
  parseForegroundWindow,
4312
4393
  chooseWebViewDevToolsSocket,
4313
4394
  chooseWebViewPage,
4314
- shapeNetworkCapture,
4315
- compactNetworkRecord,
4316
- pruneGeneratedArtifacts,
4317
- shouldSkipInstallerTapForInstalledPackage,
4395
+ shapeNetworkCapture,
4396
+ compactNetworkRecord,
4397
+ pruneGeneratedArtifacts,
4398
+ shouldSkipInstallerTapForInstalledPackage,
4318
4399
  shouldDismissKeyboardForPoint,
4319
4400
  shouldUseDefaultPortFallback,
4320
4401
  screenshotOutputPath,
4321
- statusSearchText,
4322
- uiautomatorLockPath,
4323
- waitTextConditionsMet,
4324
- withFileLock,
4325
- };
4402
+ statusSearchText,
4403
+ uiautomatorLockPath,
4404
+ verifyBridgeTargetPackage,
4405
+ waitTextConditionsMet,
4406
+ withFileLock,
4407
+ };
4326
4408
 
package/bin/mcp-server.js CHANGED
@@ -11,12 +11,13 @@ const supportedProtocolVersions = ['2025-06-18', '2024-11-05'];
11
11
  const defaultProtocolVersion = supportedProtocolVersions[0];
12
12
  const mcpSurface = (process.env.AI_APP_BRIDGE_MCP_SURFACE || 'compact').toLowerCase();
13
13
  const serverInstructions = [
14
- 'AI App Bridge observes and controls Android apps for agent workflows. Prefer these tools over raw adb when inspecting UI, text, WebView, logs, network, app install, launch, and permissions.',
14
+ 'AI App Bridge observes and controls Android apps for agent workflows. Prefer these tools over raw adb when inspecting UI, text, WebView, logs, network, app install, data reset, launch, and permissions.',
15
15
  'Default surface is compact: call capabilities to discover domains, then call run with a command and arguments.',
16
16
  'Always pass packageName for app-specific commands, or pass an explicit port. Do not rely on a sample/default package in MCP sessions.',
17
17
  ].join(' ');
18
18
 
19
19
  let buffer = Buffer.alloc(0);
20
+ let responseFormat = null;
20
21
 
21
22
  function startServer() {
22
23
  process.stdin.on('data', (chunk) => {
@@ -27,34 +28,77 @@ function startServer() {
27
28
  process.stdin.on('error', () => {});
28
29
  }
29
30
 
30
- function drainMessages() {
31
- while (true) {
32
- const delimiter = findHeaderDelimiter(buffer);
33
- const headerEnd = delimiter.index;
34
- if (headerEnd < 0) {
35
- return;
36
- }
37
- const header = buffer.subarray(0, headerEnd).toString('utf8');
38
- const match = /^Content-Length:\s*(\d+)$/im.exec(header);
39
- if (!match) {
40
- buffer = buffer.subarray(headerEnd + delimiter.length);
41
- continue;
42
- }
43
- const contentLength = Number(match[1]);
44
- const messageStart = headerEnd + delimiter.length;
45
- const messageEnd = messageStart + contentLength;
46
- if (buffer.length < messageEnd) {
47
- return;
48
- }
49
- const body = buffer.subarray(messageStart, messageEnd).toString('utf8');
50
- buffer = buffer.subarray(messageEnd);
51
- handleMessage(body).catch((error) => {
52
- writeLog(`unhandled message error: ${error.stack || error}`);
53
- });
54
- }
55
- }
56
-
57
- function findHeaderDelimiter(source) {
31
+ function drainMessages() {
32
+ while (true) {
33
+ const parsed = readNextMessage(buffer);
34
+ if (!parsed) {
35
+ return;
36
+ }
37
+ buffer = parsed.remaining;
38
+ setResponseFormat(parsed.format);
39
+ handleMessage(parsed.body).catch((error) => {
40
+ writeLog(`unhandled message error: ${error.stack || error}`);
41
+ });
42
+ }
43
+ }
44
+
45
+ function readNextMessage(source) {
46
+ const text = source.toString('utf8');
47
+ if (/^Content-Length:/i.test(text)) {
48
+ return readContentLengthMessage(source);
49
+ }
50
+ return readLineJsonMessage(source);
51
+ }
52
+
53
+ function readContentLengthMessage(source) {
54
+ const delimiter = findHeaderDelimiter(source);
55
+ const headerEnd = delimiter.index;
56
+ if (headerEnd < 0) {
57
+ return null;
58
+ }
59
+ const header = source.subarray(0, headerEnd).toString('utf8');
60
+ const match = /^Content-Length:\s*(\d+)$/im.exec(header);
61
+ if (!match) {
62
+ return {
63
+ body: source.subarray(headerEnd + delimiter.length).toString('utf8'),
64
+ format: 'frame',
65
+ remaining: Buffer.alloc(0),
66
+ };
67
+ }
68
+ const contentLength = Number(match[1]);
69
+ const messageStart = headerEnd + delimiter.length;
70
+ const messageEnd = messageStart + contentLength;
71
+ if (source.length < messageEnd) {
72
+ return null;
73
+ }
74
+ return {
75
+ body: source.subarray(messageStart, messageEnd).toString('utf8'),
76
+ format: 'frame',
77
+ remaining: source.subarray(messageEnd),
78
+ };
79
+ }
80
+
81
+ function readLineJsonMessage(source) {
82
+ const lfIndex = source.indexOf('\n');
83
+ if (lfIndex < 0) {
84
+ return null;
85
+ }
86
+ const lineEnd = lfIndex > 0 && source[lfIndex - 1] === 13 ? lfIndex - 1 : lfIndex;
87
+ const body = source.subarray(0, lineEnd).toString('utf8');
88
+ return {
89
+ body,
90
+ format: 'line',
91
+ remaining: source.subarray(lfIndex + 1),
92
+ };
93
+ }
94
+
95
+ function setResponseFormat(format) {
96
+ if (!responseFormat) {
97
+ responseFormat = format;
98
+ }
99
+ }
100
+
101
+ function findHeaderDelimiter(source) {
58
102
  const crlfIndex = source.indexOf('\r\n\r\n');
59
103
  const lfIndex = source.indexOf('\n\n');
60
104
  if (crlfIndex < 0) {
@@ -264,14 +308,15 @@ function fullToolDefinitions() {
264
308
  bridgeTool('events', 'Read generic in-app event records.'),
265
309
  bridgeTool('uia_tree', 'Read UIAutomator XML for the current device window.'),
266
310
  bridgeTool('screenshot', 'Capture an ADB screenshot.'),
267
- bridgeTool('install_apk', 'Install an APK through ADB while assisting device-side package-installer confirmation screens with UIAutomator.', {
268
- apkPath: { type: 'string', description: 'Absolute or workspace-relative APK path.' },
269
- allowDowngrade: { type: 'boolean', description: 'Pass -d to adb install.' },
270
- streaming: { type: 'boolean', description: 'Use streaming install instead of the default --no-streaming mode.' },
271
- installTimeoutMs: { type: 'number', description: 'Maximum time for adb install. Defaults to 180000 ms.' },
272
- installerTimeoutMs: { type: 'number', description: 'Maximum time to keep assisting installer screens after adb install exits. Defaults to 90000 ms.' },
273
- intervalMs: { type: 'number', description: 'Installer polling interval. Defaults to 700 ms.' },
274
- }, ['apkPath']),
311
+ bridgeTool('install_apk', 'Install an APK through ADB while assisting device-side package-installer confirmation screens with UIAutomator.', {
312
+ apkPath: { type: 'string', description: 'Absolute or workspace-relative APK path.' },
313
+ allowDowngrade: { type: 'boolean', description: 'Pass -d to adb install.' },
314
+ streaming: { type: 'boolean', description: 'Use streaming install instead of the default --no-streaming mode.' },
315
+ installTimeoutMs: { type: 'number', description: 'Maximum time for adb install. Defaults to 180000 ms.' },
316
+ installerTimeoutMs: { type: 'number', description: 'Maximum time to keep assisting installer screens after adb install exits. Defaults to 90000 ms.' },
317
+ intervalMs: { type: 'number', description: 'Installer polling interval. Defaults to 700 ms.' },
318
+ }, ['apkPath']),
319
+ bridgeTool('clear_app_data', 'Clear target app local data through the bridge runtime. Requires packageName so it cannot target the sample package by default.', {}, ['packageName']),
275
320
  bridgeTool('launch_app', 'Launch the target package LAUNCHER Activity. If multiple launcher Activities exist, returns launcher_ambiguous with candidates unless activity or component is explicit.', launchProperties()),
276
321
  bridgeTool('launch_activity', 'Launch an explicit Android Activity component, optionally with action/data/category/string extras.', launchProperties()),
277
322
  bridgeTool('launch_native_test', 'Launch the debug native Android bridge test Activity.'),
@@ -405,6 +450,7 @@ const commandDefinitions = [
405
450
  { command: 'events', domain: 'core', summary: 'Read in-app event records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
406
451
  { command: 'logcat', domain: 'diagnostics', summary: 'Read Android logcat with optional app pid, tag, level, and grep filters.', options: ['serial', 'packageName', 'pid', 'appPid', 'tag', 'level', 'grep', 'lines', 'since', 'follow', 'durationSec', 'clear'] },
407
452
  { command: 'install-apk', domain: 'app', summary: 'Install an APK and assist device-side installer confirmation screens.', options: ['serial', 'packageName', 'apkPath', 'allowDowngrade', 'streaming', 'installTimeoutMs', 'installerTimeoutMs', 'intervalMs'] },
453
+ { command: 'clear-app-data', domain: 'app', summary: 'Clear target app local data through the bridge runtime.', targetApp: true, options: ['serial', 'packageName'] },
408
454
  { command: 'launch-app', domain: 'app', summary: 'Launch the target package LAUNCHER Activity and report launcher candidates.', targetApp: true, options: ['serial', 'packageName', 'activity', 'component', 'action', 'category', 'data', 'extra'] },
409
455
  { command: 'launch-activity', domain: 'app', summary: 'Launch an explicit Android Activity component with optional string extras.', targetApp: true, options: ['serial', 'packageName', 'activity', 'component', 'action', 'category', 'data', 'extra'] },
410
456
  { command: 'launch-native-test', domain: 'app', summary: 'Launch the debug native bridge test Activity.', targetApp: true, options: ['serial', 'packageName'] },
@@ -446,6 +492,7 @@ const commandDefinitions = [
446
492
  { command: 'webview-console', domain: 'webview', summary: 'Capture WebView console/log events through CDP.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'durationMs', 'script', 'maxEvents'] },
447
493
  { command: 'forward', domain: 'advanced', summary: 'Create the ADB port forward for the bridge.', targetApp: true, options: ['serial', 'packageName', 'port'] },
448
494
  { command: 'remove-forward', domain: 'advanced', summary: 'Remove the ADB port forward for the bridge.', options: ['serial', 'port'] },
495
+ { command: 'batch', domain: 'advanced', summary: 'Run multiple AI App Bridge commands serially in one MCP call.', options: ['defaults', 'steps', 'stopOnError', 'includeRaw', 'maxRawChars'] },
449
496
  { command: 'smoke', domain: 'diagnostics', summary: 'Run the native sample smoke test.', options: ['serial', 'packageName', 'outFile', 'artifactDir', 'skipFlutterLaunch'] },
450
497
  ];
451
498
 
@@ -480,6 +527,7 @@ async function callTool(name, args) {
480
527
  input_flutter_text: 'input-flutter-text',
481
528
  uia_tree: 'uia-tree',
482
529
  install_apk: 'install-apk',
530
+ clear_app_data: 'clear-app-data',
483
531
  launch_app: 'launch-app',
484
532
  launch_activity: 'launch-activity',
485
533
  launch_native_test: 'launch-native-test',
@@ -525,7 +573,7 @@ function capabilityPayload(args = {}) {
525
573
  return {
526
574
  ok: true,
527
575
  surface: mcpSurface === 'full' || mcpSurface === 'legacy' ? 'full' : 'compact',
528
- usage: 'Use run with one of these command names. Prefer packageName for app-specific commands; install-apk, launch-app, UI, WebView, logcat, network, and permission workflows are supported.',
576
+ usage: 'Use run with one of these command names. Prefer packageName for app-specific commands; install-apk, clear-app-data, launch-app, UI, WebView, logcat, network, and permission workflows are supported.',
529
577
  domains,
530
578
  };
531
579
  }
@@ -552,6 +600,9 @@ async function runGeneric(args = {}) {
552
600
  commandArgs[key] = args[key];
553
601
  }
554
602
  }
603
+ if (command === 'batch') {
604
+ return runBatch(commandArgs);
605
+ }
555
606
  return runBridgeChecked(command, commandArgs);
556
607
  }
557
608
 
@@ -560,17 +611,240 @@ function normalizeCommandName(value) {
560
611
  }
561
612
 
562
613
  function runBridgeChecked(command, args = {}) {
614
+ if (command === 'clear-app-data' && !args.packageName) {
615
+ return toolText('clear-app-data: packageName is required in MCP mode so the command cannot clear a default package.', true);
616
+ }
563
617
  const definition = commandByName.get(command);
564
618
  if (definition?.targetApp && !args.packageName && !args.port) {
565
619
  return toolText(`${command}: packageName or explicit port is required in MCP mode so the command cannot fall back to a default package.`, true);
566
620
  }
567
621
  return runBridge(command, args);
568
622
  }
569
-
623
+
624
+ async function runBatch(args = {}, runner = runBridgeChecked) {
625
+ const startedAtMs = Date.now();
626
+ const mode = args.mode ? String(args.mode) : 'serial';
627
+ if (mode !== 'serial') {
628
+ return toolJson({ ok: false, error: 'batch_mode_not_supported', mode }, true);
629
+ }
630
+ const steps = Array.isArray(args.steps) ? args.steps : [];
631
+ if (steps.length === 0) {
632
+ return toolJson({ ok: false, error: 'batch_steps_required' }, true);
633
+ }
634
+ const maxSteps = args.maxSteps === undefined ? 30 : Number(args.maxSteps);
635
+ if (!Number.isInteger(maxSteps) || maxSteps < 1) {
636
+ return toolJson({ ok: false, error: 'invalid_max_steps', maxSteps: args.maxSteps }, true);
637
+ }
638
+ if (steps.length > maxSteps) {
639
+ return toolJson({ ok: false, error: 'batch_too_many_steps', stepCount: steps.length, maxSteps }, true);
640
+ }
641
+
642
+ const defaults = args.defaults && typeof args.defaults === 'object' ? { ...args.defaults } : {};
643
+ for (const key of ['adb', 'serial', 'port', 'packageName', 'artifactDir']) {
644
+ if (args[key] !== undefined && defaults[key] === undefined) {
645
+ defaults[key] = args[key];
646
+ }
647
+ }
648
+
649
+ const normalizedSteps = [];
650
+ const seenIds = new Set();
651
+ for (let index = 0; index < steps.length; index += 1) {
652
+ const rawStep = steps[index] && typeof steps[index] === 'object' ? steps[index] : {};
653
+ const stepId = String(rawStep.id || `step_${index + 1}`);
654
+ if (seenIds.has(stepId)) {
655
+ return toolJson({ ok: false, error: 'duplicate_batch_step_id', stepId }, true);
656
+ }
657
+ seenIds.add(stepId);
658
+ const command = normalizeCommandName(rawStep.command);
659
+ if (!commandByName.has(command)) {
660
+ return toolJson({ ok: false, error: 'unknown_batch_step_command', stepId, command: rawStep.command || '' }, true);
661
+ }
662
+ if (command === 'batch') {
663
+ return toolJson({ ok: false, error: 'nested_batch_not_supported', stepId }, true);
664
+ }
665
+ normalizedSteps.push({ ...rawStep, id: stepId, command });
666
+ }
667
+
668
+ const stopOnError = args.stopOnError !== false;
669
+ const includeRaw = Boolean(args.includeRaw);
670
+ const maxRawChars = args.maxRawChars === undefined ? 4000 : Number(args.maxRawChars);
671
+ if (!Number.isInteger(maxRawChars) || maxRawChars < 0) {
672
+ return toolJson({ ok: false, error: 'invalid_max_raw_chars', maxRawChars: args.maxRawChars }, true);
673
+ }
674
+ const results = [];
675
+ let stopped = false;
676
+
677
+ for (const step of normalizedSteps) {
678
+ if (stopped) {
679
+ results.push({
680
+ id: step.id,
681
+ command: step.command,
682
+ status: 'skipped',
683
+ ok: false,
684
+ skipped: true,
685
+ reason: 'stopOnError',
686
+ });
687
+ continue;
688
+ }
689
+
690
+ const stepStartedAtMs = Date.now();
691
+ const stepArgs = {
692
+ ...defaults,
693
+ ...(step.arguments && typeof step.arguments === 'object' ? step.arguments : {}),
694
+ };
695
+ for (const key of ['adb', 'serial', 'port', 'packageName']) {
696
+ if (step[key] !== undefined) {
697
+ stepArgs[key] = step[key];
698
+ }
699
+ }
700
+ try {
701
+ const toolResult = await runner(step.command, stepArgs);
702
+ const parsed = parseToolResult(toolResult);
703
+ const passed = !parsed.isError && parsed.payload?.ok !== false;
704
+ const stepResult = {
705
+ id: step.id,
706
+ command: step.command,
707
+ status: passed ? 'passed' : 'failed',
708
+ ok: passed,
709
+ packageName: stepArgs.packageName,
710
+ port: stepArgs.port,
711
+ durationMs: Date.now() - stepStartedAtMs,
712
+ summary: summarizeToolPayload(parsed),
713
+ };
714
+ if (!passed) {
715
+ stepResult.error = parsed.payload?.error || firstTextLine(parsed.text) || 'command_failed';
716
+ }
717
+ if (includeRaw) {
718
+ stepResult.result = parsed.payload || undefined;
719
+ stepResult.rawText = parsed.payload ? undefined : truncateText(parsed.text, maxRawChars);
720
+ }
721
+ results.push(stepResult);
722
+ if (!passed && stopOnError) {
723
+ stopped = true;
724
+ }
725
+ } catch (error) {
726
+ const stepResult = {
727
+ id: step.id,
728
+ command: step.command,
729
+ status: 'failed',
730
+ ok: false,
731
+ packageName: stepArgs.packageName,
732
+ port: stepArgs.port,
733
+ durationMs: Date.now() - stepStartedAtMs,
734
+ error: error.message || String(error),
735
+ };
736
+ results.push(stepResult);
737
+ if (stopOnError) {
738
+ stopped = true;
739
+ }
740
+ }
741
+ }
742
+
743
+ const failed = results.filter((item) => item.status === 'failed').length;
744
+ const skipped = results.filter((item) => item.status === 'skipped').length;
745
+ const passed = results.filter((item) => item.status === 'passed').length;
746
+ return toolJson({
747
+ ok: failed === 0,
748
+ batchId: args.batchId || generatedBatchId(),
749
+ mode,
750
+ stopOnError,
751
+ stepCount: normalizedSteps.length,
752
+ passed,
753
+ failed,
754
+ skipped,
755
+ durationMs: Date.now() - startedAtMs,
756
+ steps: results,
757
+ }, failed > 0);
758
+ }
759
+
570
760
  async function runBridge(command, args) {
571
761
  return runProcess(buildBridgeCliArgs(command, args));
572
762
  }
573
763
 
764
+ function parseToolResult(toolResult) {
765
+ const text = String(toolResult?.content?.[0]?.text || '');
766
+ try {
767
+ return {
768
+ isError: Boolean(toolResult?.isError),
769
+ text,
770
+ payload: JSON.parse(text),
771
+ };
772
+ } catch (_) {
773
+ return {
774
+ isError: Boolean(toolResult?.isError),
775
+ text,
776
+ payload: null,
777
+ };
778
+ }
779
+ }
780
+
781
+ function summarizeToolPayload(parsed) {
782
+ const payload = parsed.payload;
783
+ if (!payload || typeof payload !== 'object') {
784
+ return { text: truncateText(parsed.text, 500) };
785
+ }
786
+ const summary = {
787
+ ok: payload.ok,
788
+ error: payload.error || null,
789
+ };
790
+ if (payload.packageName) summary.packageName = payload.packageName;
791
+ if (payload.app?.packageName) summary.app = payload.app.packageName;
792
+ if (payload.activity) summary.activity = payload.activity;
793
+ if (payload.component) summary.component = payload.component;
794
+ if (payload.transport) summary.transport = payload.transport;
795
+ if (payload.source) summary.source = payload.source;
796
+ if (payload.path) summary.path = payload.path;
797
+ if (payload.debugBridge) {
798
+ summary.bridge = {
799
+ version: payload.debugBridge.version,
800
+ port: payload.debugBridge.port,
801
+ };
802
+ }
803
+ if (payload.count !== undefined) summary.count = payload.count;
804
+ if (payload.nodeCount !== undefined) summary.nodeCount = payload.nodeCount;
805
+ if (Array.isArray(payload.items)) summary.items = payload.items.length;
806
+ if (payload.values && typeof payload.values === 'object') {
807
+ summary.values = Object.keys(payload.values).length;
808
+ }
809
+ if (payload.counts) summary.counts = payload.counts;
810
+ if (Array.isArray(payload.requests)) summary.requests = payload.requests.length;
811
+ if (Array.isArray(payload.console)) summary.console = payload.console.length;
812
+ if (payload.flutter?.layout?.operable) {
813
+ summary.flutterOperable = {
814
+ ok: payload.flutter.layout.operable.ok,
815
+ count: payload.flutter.layout.operable.count,
816
+ };
817
+ }
818
+ if (payload.result && typeof payload.result === 'object') {
819
+ summary.result = {
820
+ ok: payload.result.ok,
821
+ error: payload.result.error || null,
822
+ value: truncateText(payload.result.value, 200),
823
+ bodyText: truncateText(payload.result.bodyText, 200),
824
+ };
825
+ }
826
+ return summary;
827
+ }
828
+
829
+ function truncateText(value, maxChars) {
830
+ if (value === undefined || value === null) return value;
831
+ const text = String(value);
832
+ if (text.length <= maxChars) return text;
833
+ return `${text.slice(0, maxChars)}...`;
834
+ }
835
+
836
+ function firstTextLine(value) {
837
+ const lines = String(value || '').split(/\r?\n/).filter((line) => line.trim());
838
+ return lines.find((line) => {
839
+ const text = line.trim().toLowerCase();
840
+ return text !== 'stderr:' && text !== 'stdout:';
841
+ }) || lines[0] || '';
842
+ }
843
+
844
+ function generatedBatchId() {
845
+ return `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
846
+ }
847
+
574
848
  function buildBridgeCliArgs(command, args = {}) {
575
849
  const cliArgs = [cliScript, command];
576
850
  addCommonArgs(cliArgs, args);
@@ -790,11 +1064,15 @@ function sendError(id, code, message) {
790
1064
  });
791
1065
  }
792
1066
 
793
- function send(message) {
794
- const body = Buffer.from(JSON.stringify(message), 'utf8');
795
- process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
796
- process.stdout.write(body);
797
- }
1067
+ function send(message) {
1068
+ const body = Buffer.from(JSON.stringify(message), 'utf8');
1069
+ if (responseFormat === 'line') {
1070
+ process.stdout.write(`${body.toString('utf8')}\n`);
1071
+ return;
1072
+ }
1073
+ process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
1074
+ process.stdout.write(body);
1075
+ }
798
1076
 
799
1077
  function writeLog(text) {
800
1078
  process.stderr.write(`${text}\n`);
@@ -806,5 +1084,7 @@ if (require.main === module) {
806
1084
 
807
1085
  module.exports = {
808
1086
  buildBridgeCliArgs,
1087
+ readNextMessage,
1088
+ runBatch,
809
1089
  startServer,
810
1090
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobileaidev/ai-app-bridge",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Desktop CLI and MCP server for AI App Bridge.",
5
5
  "repository": {
6
6
  "type": "git",