@mobileaidev/ai-app-bridge 0.2.4 → 0.2.5

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
@@ -27,6 +27,27 @@ session start. Set `AI_APP_BRIDGE_MCP_SURFACE=full` before launching
27
27
  `ai-app-bridge-mcp` only when a client needs the legacy one-tool-per-command
28
28
  surface.
29
29
 
30
+ For multi-step app automation, call `run` with `command: "batch"`. Batch steps
31
+ run serially in one MCP call, so a failed step can stop and mark the remaining
32
+ steps as skipped without mixing results from different commands:
33
+
34
+ ```json
35
+ {
36
+ "command": "batch",
37
+ "arguments": {
38
+ "defaults": {
39
+ "packageName": "io.github.mobileaidev.aiappbridge.sample"
40
+ },
41
+ "steps": [
42
+ { "id": "launch", "command": "launch-app" },
43
+ { "id": "wait-home", "command": "wait-text", "arguments": { "targetText": "Home" } },
44
+ { "id": "capture-logs", "command": "logs", "arguments": { "limit": 20 } }
45
+ ],
46
+ "stopOnError": true
47
+ }
48
+ }
49
+ ```
50
+
30
51
  WebView network and console capture use Android WebView DevTools/CDP when the
31
52
  target app is debuggable and WebView debugging is enabled.
32
53
 
@@ -976,6 +976,13 @@ function normalizeBridgeError(error) {
976
976
  suggestion: 'Check the device state and retry; if the bridge port is known, pass --port to skip package port discovery.',
977
977
  };
978
978
  }
979
+ if (error?.aiAppBridgePackageMismatch || lower.includes('bridge package mismatch')) {
980
+ return {
981
+ code: 'bridge_package_mismatch',
982
+ message,
983
+ suggestion: 'The resolved bridge port belongs to another package. Relaunch the target app and retry with the explicit packageName.',
984
+ };
985
+ }
979
986
  if (error?.aiAppBridgePortDiscovery || lower.includes('bridge port discovery failed') || lower.includes('run-as') || lower.includes('package not found')) {
980
987
  return {
981
988
  code: 'bridge_port_discovery_failed',
@@ -1001,21 +1008,38 @@ function firstErrorLine(error) {
1001
1008
  return String(error?.message || error || 'unknown_error').split(/\r?\n/).find(Boolean) || 'unknown_error';
1002
1009
  }
1003
1010
 
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
- }
1011
+ async function bridgeGet(ctx, requestPath) {
1012
+ await ensureForward(ctx);
1013
+ const body = await httpGet(bridgeUrl(ctx, requestPath));
1014
+ const payload = JSON.parse(body);
1015
+ verifyBridgeTargetPackage(ctx, payload, requestPath);
1016
+ return payload;
1017
+ }
1018
+
1019
+ async function bridgePost(ctx, requestPath, payload) {
1020
+ await ensureForward(ctx);
1021
+ const body = await httpPost(bridgeUrl(ctx, requestPath), payload);
1022
+ const responsePayload = JSON.parse(body);
1023
+ verifyBridgeTargetPackage(ctx, responsePayload, requestPath);
1024
+ return responsePayload;
1025
+ }
1026
+
1027
+ function bridgeUrl(ctx, requestPath) {
1028
+ return `http://127.0.0.1:${ctx.hostPort || ctx.port}${requestPath}`;
1029
+ }
1030
+
1031
+ function verifyBridgeTargetPackage(ctx, payload, requestPath) {
1032
+ if (!ctx.explicitPackageName || !payload || typeof payload !== 'object') return;
1033
+ const responsePackageName = payload.app && typeof payload.app === 'object'
1034
+ ? payload.app.packageName
1035
+ : undefined;
1036
+ if (!responsePackageName || responsePackageName === ctx.packageName) return;
1037
+ const error = new Error(`bridge package mismatch for ${requestPath}: expected ${ctx.packageName}, got ${responsePackageName}`);
1038
+ error.aiAppBridgePackageMismatch = true;
1039
+ error.expectedPackageName = ctx.packageName;
1040
+ error.actualPackageName = responsePackageName;
1041
+ throw error;
1042
+ }
1019
1043
 
1020
1044
  function httpGet(url) {
1021
1045
  return new Promise((resolve, reject) => {
@@ -4311,16 +4335,17 @@ module.exports = {
4311
4335
  parseForegroundWindow,
4312
4336
  chooseWebViewDevToolsSocket,
4313
4337
  chooseWebViewPage,
4314
- shapeNetworkCapture,
4315
- compactNetworkRecord,
4316
- pruneGeneratedArtifacts,
4317
- shouldSkipInstallerTapForInstalledPackage,
4338
+ shapeNetworkCapture,
4339
+ compactNetworkRecord,
4340
+ pruneGeneratedArtifacts,
4341
+ shouldSkipInstallerTapForInstalledPackage,
4318
4342
  shouldDismissKeyboardForPoint,
4319
4343
  shouldUseDefaultPortFallback,
4320
4344
  screenshotOutputPath,
4321
- statusSearchText,
4322
- uiautomatorLockPath,
4323
- waitTextConditionsMet,
4324
- withFileLock,
4325
- };
4345
+ statusSearchText,
4346
+ uiautomatorLockPath,
4347
+ verifyBridgeTargetPackage,
4348
+ waitTextConditionsMet,
4349
+ withFileLock,
4350
+ };
4326
4351
 
package/bin/mcp-server.js CHANGED
@@ -446,6 +446,7 @@ const commandDefinitions = [
446
446
  { 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
447
  { command: 'forward', domain: 'advanced', summary: 'Create the ADB port forward for the bridge.', targetApp: true, options: ['serial', 'packageName', 'port'] },
448
448
  { command: 'remove-forward', domain: 'advanced', summary: 'Remove the ADB port forward for the bridge.', options: ['serial', 'port'] },
449
+ { command: 'batch', domain: 'advanced', summary: 'Run multiple AI App Bridge commands serially in one MCP call.', options: ['defaults', 'steps', 'stopOnError', 'includeRaw', 'maxRawChars'] },
449
450
  { command: 'smoke', domain: 'diagnostics', summary: 'Run the native sample smoke test.', options: ['serial', 'packageName', 'outFile', 'artifactDir', 'skipFlutterLaunch'] },
450
451
  ];
451
452
 
@@ -552,6 +553,9 @@ async function runGeneric(args = {}) {
552
553
  commandArgs[key] = args[key];
553
554
  }
554
555
  }
556
+ if (command === 'batch') {
557
+ return runBatch(commandArgs);
558
+ }
555
559
  return runBridgeChecked(command, commandArgs);
556
560
  }
557
561
 
@@ -566,11 +570,231 @@ function runBridgeChecked(command, args = {}) {
566
570
  }
567
571
  return runBridge(command, args);
568
572
  }
569
-
573
+
574
+ async function runBatch(args = {}, runner = runBridgeChecked) {
575
+ const startedAtMs = Date.now();
576
+ const mode = args.mode ? String(args.mode) : 'serial';
577
+ if (mode !== 'serial') {
578
+ return toolJson({ ok: false, error: 'batch_mode_not_supported', mode }, true);
579
+ }
580
+ const steps = Array.isArray(args.steps) ? args.steps : [];
581
+ if (steps.length === 0) {
582
+ return toolJson({ ok: false, error: 'batch_steps_required' }, true);
583
+ }
584
+ const maxSteps = args.maxSteps === undefined ? 30 : Number(args.maxSteps);
585
+ if (!Number.isInteger(maxSteps) || maxSteps < 1) {
586
+ return toolJson({ ok: false, error: 'invalid_max_steps', maxSteps: args.maxSteps }, true);
587
+ }
588
+ if (steps.length > maxSteps) {
589
+ return toolJson({ ok: false, error: 'batch_too_many_steps', stepCount: steps.length, maxSteps }, true);
590
+ }
591
+
592
+ const defaults = args.defaults && typeof args.defaults === 'object' ? { ...args.defaults } : {};
593
+ for (const key of ['adb', 'serial', 'port', 'packageName', 'artifactDir']) {
594
+ if (args[key] !== undefined && defaults[key] === undefined) {
595
+ defaults[key] = args[key];
596
+ }
597
+ }
598
+
599
+ const normalizedSteps = [];
600
+ const seenIds = new Set();
601
+ for (let index = 0; index < steps.length; index += 1) {
602
+ const rawStep = steps[index] && typeof steps[index] === 'object' ? steps[index] : {};
603
+ const stepId = String(rawStep.id || `step_${index + 1}`);
604
+ if (seenIds.has(stepId)) {
605
+ return toolJson({ ok: false, error: 'duplicate_batch_step_id', stepId }, true);
606
+ }
607
+ seenIds.add(stepId);
608
+ const command = normalizeCommandName(rawStep.command);
609
+ if (!commandByName.has(command)) {
610
+ return toolJson({ ok: false, error: 'unknown_batch_step_command', stepId, command: rawStep.command || '' }, true);
611
+ }
612
+ if (command === 'batch') {
613
+ return toolJson({ ok: false, error: 'nested_batch_not_supported', stepId }, true);
614
+ }
615
+ normalizedSteps.push({ ...rawStep, id: stepId, command });
616
+ }
617
+
618
+ const stopOnError = args.stopOnError !== false;
619
+ const includeRaw = Boolean(args.includeRaw);
620
+ const maxRawChars = args.maxRawChars === undefined ? 4000 : Number(args.maxRawChars);
621
+ if (!Number.isInteger(maxRawChars) || maxRawChars < 0) {
622
+ return toolJson({ ok: false, error: 'invalid_max_raw_chars', maxRawChars: args.maxRawChars }, true);
623
+ }
624
+ const results = [];
625
+ let stopped = false;
626
+
627
+ for (const step of normalizedSteps) {
628
+ if (stopped) {
629
+ results.push({
630
+ id: step.id,
631
+ command: step.command,
632
+ status: 'skipped',
633
+ ok: false,
634
+ skipped: true,
635
+ reason: 'stopOnError',
636
+ });
637
+ continue;
638
+ }
639
+
640
+ const stepStartedAtMs = Date.now();
641
+ const stepArgs = {
642
+ ...defaults,
643
+ ...(step.arguments && typeof step.arguments === 'object' ? step.arguments : {}),
644
+ };
645
+ for (const key of ['adb', 'serial', 'port', 'packageName']) {
646
+ if (step[key] !== undefined) {
647
+ stepArgs[key] = step[key];
648
+ }
649
+ }
650
+ try {
651
+ const toolResult = await runner(step.command, stepArgs);
652
+ const parsed = parseToolResult(toolResult);
653
+ const passed = !parsed.isError && parsed.payload?.ok !== false;
654
+ const stepResult = {
655
+ id: step.id,
656
+ command: step.command,
657
+ status: passed ? 'passed' : 'failed',
658
+ ok: passed,
659
+ packageName: stepArgs.packageName,
660
+ port: stepArgs.port,
661
+ durationMs: Date.now() - stepStartedAtMs,
662
+ summary: summarizeToolPayload(parsed),
663
+ };
664
+ if (!passed) {
665
+ stepResult.error = parsed.payload?.error || firstTextLine(parsed.text) || 'command_failed';
666
+ }
667
+ if (includeRaw) {
668
+ stepResult.result = parsed.payload || undefined;
669
+ stepResult.rawText = parsed.payload ? undefined : truncateText(parsed.text, maxRawChars);
670
+ }
671
+ results.push(stepResult);
672
+ if (!passed && stopOnError) {
673
+ stopped = true;
674
+ }
675
+ } catch (error) {
676
+ const stepResult = {
677
+ id: step.id,
678
+ command: step.command,
679
+ status: 'failed',
680
+ ok: false,
681
+ packageName: stepArgs.packageName,
682
+ port: stepArgs.port,
683
+ durationMs: Date.now() - stepStartedAtMs,
684
+ error: error.message || String(error),
685
+ };
686
+ results.push(stepResult);
687
+ if (stopOnError) {
688
+ stopped = true;
689
+ }
690
+ }
691
+ }
692
+
693
+ const failed = results.filter((item) => item.status === 'failed').length;
694
+ const skipped = results.filter((item) => item.status === 'skipped').length;
695
+ const passed = results.filter((item) => item.status === 'passed').length;
696
+ return toolJson({
697
+ ok: failed === 0,
698
+ batchId: args.batchId || generatedBatchId(),
699
+ mode,
700
+ stopOnError,
701
+ stepCount: normalizedSteps.length,
702
+ passed,
703
+ failed,
704
+ skipped,
705
+ durationMs: Date.now() - startedAtMs,
706
+ steps: results,
707
+ }, failed > 0);
708
+ }
709
+
570
710
  async function runBridge(command, args) {
571
711
  return runProcess(buildBridgeCliArgs(command, args));
572
712
  }
573
713
 
714
+ function parseToolResult(toolResult) {
715
+ const text = String(toolResult?.content?.[0]?.text || '');
716
+ try {
717
+ return {
718
+ isError: Boolean(toolResult?.isError),
719
+ text,
720
+ payload: JSON.parse(text),
721
+ };
722
+ } catch (_) {
723
+ return {
724
+ isError: Boolean(toolResult?.isError),
725
+ text,
726
+ payload: null,
727
+ };
728
+ }
729
+ }
730
+
731
+ function summarizeToolPayload(parsed) {
732
+ const payload = parsed.payload;
733
+ if (!payload || typeof payload !== 'object') {
734
+ return { text: truncateText(parsed.text, 500) };
735
+ }
736
+ const summary = {
737
+ ok: payload.ok,
738
+ error: payload.error || null,
739
+ };
740
+ if (payload.packageName) summary.packageName = payload.packageName;
741
+ if (payload.app?.packageName) summary.app = payload.app.packageName;
742
+ if (payload.activity) summary.activity = payload.activity;
743
+ if (payload.component) summary.component = payload.component;
744
+ if (payload.transport) summary.transport = payload.transport;
745
+ if (payload.source) summary.source = payload.source;
746
+ if (payload.path) summary.path = payload.path;
747
+ if (payload.debugBridge) {
748
+ summary.bridge = {
749
+ version: payload.debugBridge.version,
750
+ port: payload.debugBridge.port,
751
+ };
752
+ }
753
+ if (payload.count !== undefined) summary.count = payload.count;
754
+ if (payload.nodeCount !== undefined) summary.nodeCount = payload.nodeCount;
755
+ if (Array.isArray(payload.items)) summary.items = payload.items.length;
756
+ if (payload.values && typeof payload.values === 'object') {
757
+ summary.values = Object.keys(payload.values).length;
758
+ }
759
+ if (payload.counts) summary.counts = payload.counts;
760
+ if (Array.isArray(payload.requests)) summary.requests = payload.requests.length;
761
+ if (Array.isArray(payload.console)) summary.console = payload.console.length;
762
+ if (payload.flutter?.layout?.operable) {
763
+ summary.flutterOperable = {
764
+ ok: payload.flutter.layout.operable.ok,
765
+ count: payload.flutter.layout.operable.count,
766
+ };
767
+ }
768
+ if (payload.result && typeof payload.result === 'object') {
769
+ summary.result = {
770
+ ok: payload.result.ok,
771
+ error: payload.result.error || null,
772
+ value: truncateText(payload.result.value, 200),
773
+ bodyText: truncateText(payload.result.bodyText, 200),
774
+ };
775
+ }
776
+ return summary;
777
+ }
778
+
779
+ function truncateText(value, maxChars) {
780
+ if (value === undefined || value === null) return value;
781
+ const text = String(value);
782
+ if (text.length <= maxChars) return text;
783
+ return `${text.slice(0, maxChars)}...`;
784
+ }
785
+
786
+ function firstTextLine(value) {
787
+ const lines = String(value || '').split(/\r?\n/).filter((line) => line.trim());
788
+ return lines.find((line) => {
789
+ const text = line.trim().toLowerCase();
790
+ return text !== 'stderr:' && text !== 'stdout:';
791
+ }) || lines[0] || '';
792
+ }
793
+
794
+ function generatedBatchId() {
795
+ return `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
796
+ }
797
+
574
798
  function buildBridgeCliArgs(command, args = {}) {
575
799
  const cliArgs = [cliScript, command];
576
800
  addCommonArgs(cliArgs, args);
@@ -806,5 +1030,6 @@ if (require.main === module) {
806
1030
 
807
1031
  module.exports = {
808
1032
  buildBridgeCliArgs,
1033
+ runBatch,
809
1034
  startServer,
810
1035
  };
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.5",
4
4
  "description": "Desktop CLI and MCP server for AI App Bridge.",
5
5
  "repository": {
6
6
  "type": "git",