@mobileaidev/ai-app-bridge 0.3.7 → 0.4.0

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.
Files changed (64) hide show
  1. package/README.md +52 -39
  2. package/bin/ai-app-bridge.js +56 -17
  3. package/bin/command-discovery.js +19 -4
  4. package/bin/command-registry.js +21 -6
  5. package/bin/command-request.js +68 -4
  6. package/bin/execution-host.js +32 -5
  7. package/bin/execution-runtime.js +17 -10
  8. package/bin/executors/command-schema.js +1 -1
  9. package/bin/executors/preparation.js +262 -0
  10. package/bin/extraction/json-value.js +26 -0
  11. package/bin/extraction/prepare.js +57 -0
  12. package/bin/extraction/regex.js +30 -0
  13. package/bin/extraction/runner.js +77 -0
  14. package/bin/intent/install-intent.js +3 -1
  15. package/bin/ios-provider.js +2 -2
  16. package/bin/ios-wda-project.js +48 -1
  17. package/bin/mcp-server.js +15 -20
  18. package/bin/public-reply.js +184 -0
  19. package/bin/response-store.js +60 -0
  20. package/bin/runtime-client.js +32 -15
  21. package/bin/runtime-directory.js +37 -8
  22. package/bin/script/node-runtime-adapter.js +139 -123
  23. package/bin/script/python-runtime-adapter.js +1 -1
  24. package/bin/script/script-diagnostics.js +21 -0
  25. package/bin/script/script-durable-restore.js +1 -0
  26. package/bin/script/script-host-port.js +3 -2
  27. package/bin/script/script-sdk.js +39 -4
  28. package/bin/script/script-sdk.py +79 -7
  29. package/bin/script/script-session-channel.js +27 -10
  30. package/bin/script/script-supervisor.js +8 -0
  31. package/bin/shared-kernel/argument-schema.js +44 -12
  32. package/bin/shared-kernel/evidence-archive.js +2 -2
  33. package/bin/shared-kernel/evidence-schema.js +16 -1
  34. package/bin/shared-kernel/evidence-store.js +3 -3
  35. package/bin/shared-kernel/execution-contracts.js +13 -5
  36. package/bin/shared-kernel/execution-target.js +4 -0
  37. package/bin/shared-kernel/request-context.js +2 -2
  38. package/bin/target-execution.js +2 -0
  39. package/docs/COMMAND_CONTRACT.md +91 -19
  40. package/docs/EVIDENCE_ARCHIVE.md +14 -1
  41. package/docs/INSTALLATION.md +73 -0
  42. package/docs/INTENT_FOREGROUND.md +4 -1
  43. package/docs/OPTIONAL_EXECUTORS.md +41 -13
  44. package/docs/RELEASE.md +60 -113
  45. package/docs/RESPONSE_EXTRACTION.md +122 -0
  46. package/docs/SCRIPT_AUTHORING.md +125 -6
  47. package/node_modules/@mobileaidev/segmented-fact-store-native/PREBUILDS.md +29 -0
  48. package/node_modules/@mobileaidev/segmented-fact-store-native/binding-path.js +29 -0
  49. package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +1 -0
  50. package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +1 -3
  51. package/node_modules/@mobileaidev/segmented-fact-store-native/install.js +5 -0
  52. package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +11 -5
  53. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-arm64/segmented_fact_store.node +0 -0
  54. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-x64/segmented_fact_store.node +0 -0
  55. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-arm64-glibc/segmented_fact_store.node +0 -0
  56. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-x64-glibc/segmented_fact_store.node +0 -0
  57. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/manifest.json +27 -0
  58. package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/build-release-prebuilds.js +33 -0
  59. package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/stage-prebuild.js +17 -0
  60. package/package.json +12 -5
  61. package/runtime/executors/android/prepare.init.gradle +92 -0
  62. package/runtime/executors/playwright/package-lock.json +2 -2
  63. package/runtime/executors/playwright/package.json +1 -1
  64. package/skills/ai-app-bridge-use/SKILL.md +19 -4
package/README.md CHANGED
@@ -7,10 +7,11 @@ discovery. Every request checks the mapping before dispatch. Mutating requests
7
7
  are never replayed after a missing route or uncertain result. For manual cleanup,
8
8
  pass the exact serial and returned Host port to `remove-forward`.
9
9
 
10
- This source version is `0.3.7`; registry publication is a separate release step.
10
+ This source version is `0.4.0`; registry publication is a separate release step.
11
11
  The default installation includes the Script/Intent and capture contracts below.
12
12
  Local package verification does not change npm dist-tags.
13
13
  The supported Node range is `>=26.3.0 <27`; this release was checked on 26.3.0.
14
+ See [the simplest MCP setup and Host requirements](docs/INSTALLATION.md).
14
15
  See [the release guide](docs/RELEASE.md) for local packaging and coordinated publication.
15
16
 
16
17
  Optional Android Instrumentation/UI Automator/Espresso/Compose/H5, Flutter integration_test,
@@ -27,13 +28,17 @@ CLI and MCP connect to one independent local execution runtime. Every registered
27
28
  command is available from either entrypoint, including Intent, Script, Web,
28
29
  installation and permission flows. A CLI command can start a task and a later MCP
29
30
  connection can observe or decide the same operation ID. Disconnecting either
30
- client leaves tasks running. Use `runtime --operation status` to inspect its
31
- owner, `runtime --operation stop` to drain it, and task `cancel` to stop one task.
32
-
33
- CLI output is `{kind: "json"|"text"|"bytes", value, history?}`. Read the command
34
- payload from `value`; binary values are base64. Failure uses `value.ok:false` and
35
- exit code 1. MCP wraps the same payload/history in its content format. See the
36
- [shared lifecycle and configuration contract](docs/COMMAND_CONTRACT.md#shared-runtime-lifecycle).
31
+ client leaves tasks running. Use `runtime --operation status --extract null` to inspect its
32
+ owner, `runtime --operation stop --extract null` to drain it, and task `cancel` to stop one task.
33
+
34
+ Each run explicitly supplies `extract`: `null` for the original result, or
35
+ regex/JavaScript/Python for a smaller result. CLI: `--extract null`.
36
+ The compact body is `{command, execution, control, extraction, delivery, kind,
37
+ value?, failureStage?}`. Original execution facts and continuation controls stay
38
+ separate from the extracted value. Default body budget: 96 KiB UTF-8; overflow
39
+ can return a saved response ref. Retry with `response read`, not the original
40
+ action. CLI exits 0 / 1 / 2 for delivery success / execution failure or unknown /
41
+ post-success extraction or delivery failure. See [extraction and recovery](docs/RESPONSE_EXTRACTION.md).
37
42
 
38
43
  Execution operations and nested controls use strict schemas. Supply an explicit
39
44
  `operation`, use `script.target` and canonical `javascript`/`python` languages,
@@ -60,31 +65,31 @@ domains, commands, and options, then call `run` with the selected command.
60
65
 
61
66
  ```bash
62
67
  # Install the current stable release; see docs/RELEASE.md for packaging.
63
- npm install -g @mobileaidev/ai-app-bridge@0.3.7
64
-
65
- ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
66
- ai-app-bridge tree --package-name io.github.mobileaidev.aiappbridge.sample
67
- ai-app-bridge clear-app-data --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample
68
- ai-app-bridge launch-app --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample
69
- ai-app-bridge launch-activity --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample --activity .MainActivity --extra route=/home
70
- ai-app-bridge screenshot --package-name io.github.mobileaidev.aiappbridge.sample
71
- ai-app-bridge input-text --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample --text "中文输入" --hide-keyboard
72
- ai-app-bridge network --package-name io.github.mobileaidev.aiappbridge.sample --compact --url-filter /api/
73
- ai-app-bridge webview-network --package-name io.github.mobileaidev.aiappbridge.sample --duration-ms 3000
74
- ai-app-bridge ios-devices
75
- ai-app-bridge ios-doctor --device-id <device-or-udid> --bundle-id <ios.bundle.id> --wda-runner-bundle-id <runner-from-setup>
76
- ai-app-bridge ios-setup --device-id <device-or-udid> --bundle-id <ios.bundle.id> --team-id <APPLE_TEAM_ID> --start-wda
77
- ai-app-bridge ios-status --device-id <device-or-udid> --bundle-id <ios.bundle.id>
78
- ai-app-bridge ios-execution --operation status --device-id <device-or-udid> --bundle-id <ios.bundle.id>
79
- ai-app-bridge ios-execution --operation reconcile --device-id <device-or-udid> --bundle-id <original.ios.bundle.id>
80
- ai-app-bridge ios-execution --operation reconcile --kind wda --device-id <device-or-udid> --wda-runner-bundle-id <runner.bundle.id>
81
- ai-app-bridge ios-wda-session --operation create --device-id <device-or-udid> --wda-runner-bundle-id <runner-from-setup> --bundle-id <ios.bundle.id>
82
- ai-app-bridge ios-tap --device-id <device-or-udid> --wda-runner-bundle-id <runner-from-setup> --bundle-id <ios.bundle.id> --wda-session-id <created-session> --tap-x 120 --tap-y 360
83
- ai-app-bridge ios-input --device-id <device-or-udid> --wda-runner-bundle-id <runner-from-setup> --bundle-id <ios.bundle.id> --wda-session-id <created-session> --accessibility-id sample_text_field --clear-first --text "hello"
84
- ai-app-bridge web-session-start --web-port 18180
85
- ai-app-bridge runtime --operation status
86
- ai-app-bridge thaw-app --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample
87
- ai-app-bridge freeze-app --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample
68
+ npm install -g @mobileaidev/ai-app-bridge@0.4.0
69
+
70
+ ai-app-bridge status --extract null --package-name io.github.mobileaidev.aiappbridge.sample
71
+ ai-app-bridge tree --extract null --package-name io.github.mobileaidev.aiappbridge.sample
72
+ ai-app-bridge clear-app-data --extract null --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample
73
+ ai-app-bridge launch-app --extract null --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample
74
+ ai-app-bridge launch-activity --extract null --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample --activity .MainActivity --extra route=/home
75
+ ai-app-bridge screenshot --extract null --package-name io.github.mobileaidev.aiappbridge.sample
76
+ ai-app-bridge input-text --extract null --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample --text "中文输入" --hide-keyboard
77
+ ai-app-bridge network --extract null --package-name io.github.mobileaidev.aiappbridge.sample --compact --url-filter /api/
78
+ ai-app-bridge webview-network --extract null --package-name io.github.mobileaidev.aiappbridge.sample --duration-ms 3000
79
+ ai-app-bridge ios-devices --extract null
80
+ ai-app-bridge ios-doctor --extract null --device-id <device-or-udid> --bundle-id <ios.bundle.id> --wda-runner-bundle-id <runner-from-setup>
81
+ ai-app-bridge ios-setup --extract null --device-id <device-or-udid> --bundle-id <ios.bundle.id> --team-id <APPLE_TEAM_ID> --start-wda
82
+ ai-app-bridge ios-status --extract null --device-id <device-or-udid> --bundle-id <ios.bundle.id>
83
+ ai-app-bridge ios-execution --extract null --operation status --device-id <device-or-udid> --bundle-id <ios.bundle.id>
84
+ ai-app-bridge ios-execution --extract null --operation reconcile --device-id <device-or-udid> --bundle-id <original.ios.bundle.id>
85
+ ai-app-bridge ios-execution --extract null --operation reconcile --kind wda --device-id <device-or-udid> --wda-runner-bundle-id <runner.bundle.id>
86
+ ai-app-bridge ios-wda-session --extract null --operation create --device-id <device-or-udid> --wda-runner-bundle-id <runner-from-setup> --bundle-id <ios.bundle.id>
87
+ ai-app-bridge ios-tap --extract null --device-id <device-or-udid> --wda-runner-bundle-id <runner-from-setup> --bundle-id <ios.bundle.id> --wda-session-id <created-session> --tap-x 120 --tap-y 360
88
+ ai-app-bridge ios-input --extract null --device-id <device-or-udid> --wda-runner-bundle-id <runner-from-setup> --bundle-id <ios.bundle.id> --wda-session-id <created-session> --accessibility-id sample_text_field --clear-first --text "hello"
89
+ ai-app-bridge web-session-start --extract null --web-port 18180
90
+ ai-app-bridge runtime --extract null --operation status
91
+ ai-app-bridge thaw-app --extract null --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample
92
+ ai-app-bridge freeze-app --extract null --serial DEVICE --package-name io.github.mobileaidev.aiappbridge.sample
88
93
  ai-app-bridge-mcp --help
89
94
  ```
90
95
 
@@ -203,7 +208,13 @@ Script/Intent history to inspect execution records separately:
203
208
  ```json
204
209
  {
205
210
  "command": "events",
206
- "arguments": { "serial": "DEVICE", "packageName": "com.example.app", "history": true, "limit": 100 }
211
+ "extract": null,
212
+ "arguments": {
213
+ "serial": "DEVICE",
214
+ "packageName": "com.example.app",
215
+ "history": true,
216
+ "limit": 100
217
+ }
207
218
  }
208
219
  ```
209
220
 
@@ -396,10 +407,12 @@ entry point explicitly.
396
407
 
397
408
  `ai-app-bridge --version` (also `-V`) reads the installed entrypoint's package
398
409
  version without requiring a device. Upgrading npm replaces files; it does not
399
- replace an MCP process already connected to Cursor or another client. Stop the
400
- shared Runtime with `ai-app-bridge runtime --operation stop` when existing work
401
- has finished, then reconnect the client's MCP server. The MCP initialize
410
+ replace an MCP process already connected to Cursor or another client. Reconnect
411
+ the client's MCP server after upgrading. Follow the mismatch diagnostic: upgrade
412
+ an older client first; only stop an older Runtime after its existing work finishes,
413
+ using `ai-app-bridge runtime --operation stop --extract null`. The MCP initialize
402
414
  response reports `serverInfo.version`; refresh cached tool descriptions in the
403
415
  client when they still show removed commands such as `batch` or `smoke`.
404
- A running Runtime from different source code reports `runtime_code_mismatch`
405
- until explicitly stopped, so in-flight work is not silently moved to new code.
416
+ A same-version fingerprint mismatch requires aligning the source and Node
417
+ environment; the hash alone does not identify which side is newer. In-flight
418
+ work is not silently moved to new code.
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const { CommandError, commandFailure } = require('./command-errors');
4
+ const { CommandError } = require('./command-errors');
5
5
  const { commandDefinitions, isolatedCommandDefinitions, parseCliOptions } = require('./command-registry');
6
- const { commandInputSchema } = require('./command-discovery');
7
- const { encodeReply } = require('./runtime-protocol');
6
+ const { commandHelpSchema } = require('./command-discovery');
7
+ const { publicFailure, exitCodeFor } = require('./public-reply');
8
+ const { publicOutputLimit } = require('./command-request');
8
9
  const runtime = require('./runtime-client');
9
10
 
10
11
  const helpText = `Usage: ai-app-bridge <command> [options]
@@ -25,14 +26,18 @@ Intent decide also accepts --platform, --provider and --action schema filters.
25
26
  CLI flags use kebab-case, for example --package-name, --tap-x, --timeout-ms.
26
27
  Objects, arrays, nullable objects and Script decisions use JSON flag values.
27
28
  Only --category and --extra repeat as individual strings.
28
- Results are JSON: {kind: "json"|"text"|"bytes", value, history?}.
29
- Bytes are base64; value.ok:false exits with code 1. Help is plain text.
29
+ Every command requires --extract: null delivers the command's own result.
30
+ Results are one line of compact JSON: {command, execution, control, extraction,
31
+ delivery, kind: "json"|"text"|"bytes", value, failureStage?}. Bytes are base64.
32
+ Exit 0: delivered as requested. 1: rejected, failed or unknown execution.
33
+ 2: the command succeeded but extraction or delivery failed. Help is plain text.
30
34
  Relative file paths use the calling directory. Script cwd defaults to it.
31
- Example: ai-app-bridge script --operation start --script '{"schemaVersion":"aab.code-script/v1","language":"javascript","sourcePath":"./regression.js"}'
35
+ Example: ai-app-bridge script --extract null --operation start --script '{"schemaVersion":"aab.code-script/v1","language":"javascript","sourcePath":"./regression.js","target":{"platform":"android","serial":"<serial>","packageName":"<package>"}}'
32
36
  `;
33
37
 
34
38
  async function main() {
35
39
  let command;
40
+ let maxBytes;
36
41
  const connection = new AbortController();
37
42
  const disconnect = () => connection.abort();
38
43
  process.once('SIGINT', disconnect);
@@ -46,29 +51,61 @@ async function main() {
46
51
  if (!argv.length) { process.stdout.write(`${helpText}\n`); return; }
47
52
  const parsed = parseArgs(argv);
48
53
  command = parsed.command;
54
+ const envelopeOutput = publicFields({ output: parsed.options.output });
55
+ maxBytes = publicOutputLimit(envelopeOutput);
56
+ if (parsed.error) throw parsed.error;
49
57
  if (parsed.options.help || command === 'help') {
50
58
  const name = command === 'help' ? '' : command;
51
59
  const { help, ...filters } = parsed.options;
52
60
  if (!name && Object.keys(filters).length) throw new CommandError('invalid_argument', 'Schema filters require a command after --help.', { field: Object.keys(filters)[0] });
53
- process.stdout.write(name ? `${JSON.stringify(commandInputSchema(name, filters), null, 2)}\n` : `${helpText}\n`);
61
+ const schema = name ? commandHelpSchema(name, filters) : null;
62
+ process.stdout.write(name ? `${JSON.stringify(schema)}\n` : `${helpText}\n`);
63
+ if (schema?.ok === false) process.exitCode = 1;
54
64
  return;
55
65
  }
56
66
  command ||= 'status';
57
- const reply = await runtime.run({ command, arguments: parseCliOptions(command, parsed.options) }, { signal: connection.signal });
58
- process.stdout.write(`${JSON.stringify(encodeReply(reply), null, 2)}\n`);
59
- if (reply.value?.ok === false) process.exitCode = 1;
67
+ // The public fields leave the CLI options before the command's own parser
68
+ // sees them; each is one JSON value and never reaches the device action.
69
+ const { extract, output, ...options } = parsed.options;
70
+ const request = { command, ...envelopeOutput };
71
+ Object.assign(request, publicFields({ extract }));
72
+ request.arguments = parseCliOptions(command, options);
73
+ const { value: reply } = await runtime.run(request, { signal: connection.signal });
74
+ process.stdout.write(`${JSON.stringify(reply)}\n`);
75
+ process.exitCode = exitCodeFor(reply);
60
76
  } catch (error) {
61
- process.stdout.write(`${JSON.stringify(encodeReply({ value: commandFailure(error, command) }), null, 2)}\n`);
62
- process.exitCode = 1;
77
+ const reply = publicFailure({ command, stage: 'validation', error, maxBytes });
78
+ process.stdout.write(`${JSON.stringify(reply)}\n`);
79
+ process.exitCode = exitCodeFor(reply);
63
80
  } finally {
64
81
  process.removeListener('SIGINT', disconnect);
65
82
  process.removeListener('SIGTERM', disconnect);
66
83
  }
67
84
  }
68
85
 
86
+ // --extract and --output carry JSON. A flag without a value is a missing value,
87
+ // not `true`; the JSON string "null" is not null.
88
+ function publicFields(fields) {
89
+ const parsed = {};
90
+ for (const [name, raw] of Object.entries(fields)) {
91
+ if (raw === undefined) continue;
92
+ if (raw === true) throw new CommandError('missing_argument', `--${name} requires one JSON value${name === 'extract' ? '; use --extract null for this command\'s own result' : ''}.`, { field: name });
93
+ try { parsed[name] = JSON.parse(raw); }
94
+ catch { throw new CommandError('invalid_argument', `--${name} must be one JSON value${name === 'extract' ? ', for example null' : ''}: ${raw}`, { field: name }); }
95
+ }
96
+ return parsed;
97
+ }
98
+
69
99
  function parseArgs(argv) {
70
100
  const options = {};
71
101
  let command = '';
102
+ let error;
103
+ // Finish reading named tokens after a lexical error so its reply can still
104
+ // honor a valid output budget. The first error rejects the entire request.
105
+ const append = (name, value) => {
106
+ try { appendOption(options, name, value); }
107
+ catch (cause) { error ||= cause; }
108
+ };
72
109
  for (let index = 0; index < argv.length; index += 1) {
73
110
  const arg = argv[index];
74
111
  if (!arg.startsWith('--') && !command) {
@@ -76,22 +113,24 @@ function parseArgs(argv) {
76
113
  continue;
77
114
  }
78
115
  if (!arg.startsWith('--')) {
79
- throw new CommandError('unexpected_argument', `Unexpected positional argument: ${arg}. Supply values through named flags.`, { field: `argv[${index}]` });
116
+ error ||= new CommandError('unexpected_argument', `Unexpected positional argument: ${arg}. Supply values through named flags.`, { field: `argv[${index}]` });
117
+ continue;
80
118
  }
81
119
  const rawName = arg.slice(2);
82
120
  if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(rawName)) {
83
- throw new CommandError('invalid_argument', `Invalid CLI flag: ${arg}. Use --kebab-case flags followed by separate value tokens.`, { field: `argv[${index}]` });
121
+ error ||= new CommandError('invalid_argument', `Invalid CLI flag: ${arg}. Use --kebab-case flags followed by separate value tokens.`, { field: `argv[${index}]` });
122
+ continue;
84
123
  }
85
124
  const name = rawName.replace(/-([a-z])/g, (_, value) => value.toUpperCase());
86
125
  const next = argv[index + 1];
87
126
  if (name === 'help' || next === undefined || next.startsWith('--')) {
88
- appendOption(options, name, true);
127
+ append(name, true);
89
128
  continue;
90
129
  }
91
- appendOption(options, name, next);
130
+ append(name, next);
92
131
  index += 1;
93
132
  }
94
- return { command, options };
133
+ return { command, options, ...(error ? { error } : {}) };
95
134
  }
96
135
 
97
136
  function appendOption(options, name, value) {
@@ -27,7 +27,19 @@ const commandDomains = {
27
27
  };
28
28
  const supportedTargetsText = `AI App Bridge supports ${supportedTargets.join('; ')}.`;
29
29
  const commandDomainsText = `Command domains: ${Object.entries(commandDomains).map(([domain, summary]) => `${domain}(${summary})`).join('; ')}.`;
30
- const discoveryText = 'Use capabilities with a domain for a command directory, then request one command and operation when applicable. For intent decide, narrow by the actual platform, provider and action. Omit filters for the complete command contract; includeOptions:true explicitly expands a directory. Execute with run and command-specific arguments.';
30
+ const discoveryText = 'Use capabilities with a domain for a command directory, then request one command and operation when applicable. For intent decide, narrow by the actual platform, provider and action. Discovery output is limited to 96 KiB; narrow the query if a full schema or includeOptions:true exceeds this budget. Execute with run, command-specific arguments and explicit extract.';
31
+
32
+ function discoveryBudget(value) {
33
+ const limitBytes = require('./public-reply').DEFAULT_OUTPUT_BYTES;
34
+ const attemptedBytes = Buffer.byteLength(JSON.stringify(value));
35
+ if (attemptedBytes <= limitBytes) return value;
36
+ return { ok: false, error: 'discovery_output_too_large', attemptedBytes, limitBytes,
37
+ message: 'Request a command directory without includeOptions, then one command and operation. For intent decide, add platform/provider/action. No schema was truncated.',
38
+ filters: ['domain', 'command', ...schemaFilterNames], example: { command: 'intent', operation: 'start' } };
39
+ }
40
+
41
+ function capabilities(args) { return discoveryBudget(capabilityPayload(args)); }
42
+ function commandHelpSchema(command, filters) { return discoveryBudget(commandInputSchema(command, filters)); }
31
43
  function capabilityPayload(args = {}) {
32
44
  if (!args || typeof args !== 'object' || Array.isArray(args)) return { ok: false, error: 'invalid_argument', field: 'arguments', dispatched: false, ambiguous: false };
33
45
  const invalidKey = Object.keys(args).find(key => !['command', 'domain', 'includeOptions', ...schemaFilterNames].includes(key));
@@ -46,7 +58,7 @@ function capabilityPayload(args = {}) {
46
58
  return {
47
59
  ok: Boolean(definition),
48
60
  command: requestedCommand,
49
- ...(definition ? shapeCommandDefinition(definition, true, filters) : { error: 'unknown_command' }),
61
+ ...(definition ? shapeCommandDefinition(definition, true, filters) : { error: 'unknown_command', message: `Unknown command: ${requestedCommand}. Omit command, or pass a domain, to list the command directory.` }),
50
62
  };
51
63
  } catch (error) {
52
64
  if (!(error instanceof CommandError)) throw error;
@@ -56,7 +68,10 @@ function capabilityPayload(args = {}) {
56
68
 
57
69
  const requestedDomain = args.domain ? String(args.domain) : '';
58
70
  const domains = {};
59
- if (requestedDomain && !Object.hasOwn(commandDomains, requestedDomain)) return { ok: false, error: 'unknown_domain', field: 'domain', dispatched: false, ambiguous: false };
71
+ if (requestedDomain && !Object.hasOwn(commandDomains, requestedDomain)) {
72
+ return { ok: false, error: 'unknown_domain', field: 'domain', dispatched: false, ambiguous: false,
73
+ message: `Unknown domain: ${requestedDomain}. Domains: ${Object.keys(commandDomains).join(', ')}. android is a platform, not a domain: platform, provider and action narrow intent decide schemas, e.g. {"command":"intent","operation":"decide","platform":"android","provider":"native","action":"tap"}.` };
74
+ }
60
75
  for (const definition of [...isolatedCommandDefinitions, ...commandDefinitions]) {
61
76
  if (requestedDomain && definition.domain !== requestedDomain) continue;
62
77
  if (!domains[definition.domain]) domains[definition.domain] = [];
@@ -139,4 +154,4 @@ function shapeCommandDefinition(definition, includeOptions, filters = {}) {
139
154
 
140
155
 
141
156
  function normalizeCommandName(value) { return typeof value === 'string' ? value : ''; }
142
- module.exports = { capabilities: capabilityPayload, commandInputSchema, commandDomains, supportedTargets, supportedTargetsText, commandDomainsText, discoveryText };
157
+ module.exports = { capabilities, commandInputSchema, commandHelpSchema, commandDomains, supportedTargets, supportedTargetsText, commandDomainsText, discoveryText };
@@ -8,6 +8,8 @@ const { executionCommandSchema, nativeGestureSchema, nativeSelector, flutterSele
8
8
  const { flutterActionSchema, webCommandSchema } = require('./shared-kernel/provider-command-contracts');
9
9
 
10
10
  const commandDefinitions = [
11
+ { command: 'response', domain: 'evidence', summary: 'Read and re-extract one saved response using its unchanged control.source.ref. This never repeats the original action or refreshes an observation.', targetKind: 'host', options: ['operation', 'ref'] },
12
+ { command: 'executor-prepare', domain: 'advanced', summary: 'Prepare optional test executors from the existing project: generate Android androidTest entry/dependencies and matching APKs, add Flutter dev dependency and build the generated entrypoint, prepare managed iOS WDA, or install pinned Playwright/browser. Host preparation does not install or launch a mobile application. No application ID is changed. Requires app.test in Script.', targetKind: 'host', options: ['platform'] },
11
13
  { command: 'ui-observation', domain: 'core', summary: 'Start a bounded UI observation window (100–5000 ms), inspect it, or stop its lease. Off by default; provider selects native or Flutter. New windows establish a fresh baseline.', targetApp: true, options: ['operation', 'provider', 'durationMs', 'leaseId', 'packageName', 'serial'] },
12
14
  { command: 'ios-ui-observation', domain: 'ios', summary: 'Control bounded iOS native or Flutter UI observation. Off by default; expires locally even if the Host exits.', targetKind: 'ios-app', options: ['operation', 'provider', 'durationMs', 'leaseId', 'deviceId', 'bundleId'] },
13
15
  { command: 'web-ui-observation', domain: 'web', summary: 'Control bounded Web DOM change observation. Off by default; expires locally even if the Host exits.', targetKind: 'web-target', options: ['operation', 'durationMs', 'leaseId', 'sessionId', 'runtimeEpoch', 'targetId'] },
@@ -151,6 +153,7 @@ const isolatedCommandDefinitions = [
151
153
  ];
152
154
 
153
155
  const mutationCommands = new Set([
156
+ 'executor-prepare',
154
157
  'install-apk', 'clear-app-data', 'freeze-app', 'thaw-app', 'launch-app',
155
158
  'launch-activity', 'permission-grant',
156
159
  'permission-revoke', 'permission-dialog', 'appops-set',
@@ -254,12 +257,13 @@ function isMutationCommand(command, args = {}) {
254
257
  }
255
258
 
256
259
  function isAndroidMutation(command, args = {}) {
257
- return isMutationCommand(command, args) && !['runtime', 'device-ownership'].includes(command)
260
+ return isMutationCommand(command, args) && !['runtime', 'device-ownership', 'executor-prepare'].includes(command)
258
261
  && !command.startsWith('ios-') && !command.startsWith('web-');
259
262
  }
260
263
 
261
264
  function executionTimeoutMs(command, args = {}) {
262
265
  if (args.timeoutMs !== undefined) return args.timeoutMs;
266
+ if (command === 'executor-prepare') return 600000;
263
267
  if (['android-executor', 'flutter-executor'].includes(command)) return args.operation === 'open' ? 60000 : 30000;
264
268
  if (command === 'web-executor') return args.operation === 'prepare' ? 300000 : 30000;
265
269
  if (iosSdkCommands.has(command) || command === 'ios-execution' || require('./ios-wda-port').commands.has(command)) return 30000;
@@ -273,7 +277,7 @@ function executionTimeoutMs(command, args = {}) {
273
277
  // These are execution capabilities, independent of MCP/CLI transport. Script
274
278
  // permissions gate Bridge calls; trusted local code is not a process sandbox.
275
279
  const scriptPermissions = Object.freeze({
276
- 'app.test': ['web-executor', 'android-executor', 'flutter-executor'],
280
+ 'app.test': ['web-executor', 'android-executor', 'flutter-executor', 'executor-prepare'],
277
281
  'app.read': ['status', 'tree', 'uia-tree', 'screenshot', 'flutter-tree', 'flutter-nodes', 'h5-dom', 'flutter-h5-dom', 'keyboard-state', 'permission-state',
278
282
  'ios-status', 'ios-tree', 'ios-uia-tree', 'ios-screenshot', 'ios-flutter-tree', 'ios-flutter-nodes', 'ios-h5-dom', 'ios-wda-status', 'web-status', 'web-dom'],
279
283
  'capture.read': ['ui-observation', 'ios-ui-observation', 'web-ui-observation', 'logs', 'network', 'state', 'events', 'logcat', 'webview-console', 'webview-network', 'ios-logs', 'ios-network', 'ios-state', 'ios-events', 'web-logs', 'web-network', 'web-state', 'web-events'],
@@ -287,14 +291,23 @@ const workflowCommands = new Set(['install-apk', 'permission-dialog']);
287
291
  const permissionCommands = new Set(['permission-state', 'permission-grant', 'permission-revoke']);
288
292
  const expertCommands = new Set(['uia-runtime', 'freeze-app', 'thaw-app', 'flutter-action', 'h5-eval', 'flutter-h5-eval', 'ios-h5-eval', 'ios-flutter-action', 'ios-setup', 'web-command', 'forward', 'remove-forward', 'webview-pages', 'appops-set', 'permission-grant', 'permission-revoke']);
289
293
  const deviceCommands = new Set(['uia-tree', 'screenshot', 'tap', 'tap-uia-text', 'keyboard-state', 'hide-keyboard', 'swipe', 'keyevent', 'logcat', 'permission-dialog', 'install-apk', 'remove-forward']);
294
+ // Discovery is a recorded misuse of run: point at the discovery entry instead of
295
+ // only rejecting the name.
296
+ function unknownCommand(command) {
297
+ const message = command === 'capabilities'
298
+ ? 'capabilities is the discovery tool, not a run command. Call the capabilities tool (CLI: --help <command>) with an optional domain or command filter, then run one of the listed command names.'
299
+ : `Unknown command: ${command}. List command names with the capabilities tool (CLI: ai-app-bridge help).`;
300
+ return new CommandError('unknown_command', message, { field: 'command' });
301
+ }
302
+
290
303
  function commandContract(command) {
291
304
  const definition = commandByName.get(command) || isolatedByName.get(command);
292
- if (!definition) throw new CommandError('unknown_command', `Unknown command: ${command}`, { field: 'command' });
305
+ if (!definition) throw unknownCommand(command);
293
306
  const isolated = isolatedByName.has(command);
294
- const platform = command === 'runtime' ? 'host' : isolated ? (command === 'evidence' ? 'host' : 'multi')
307
+ const platform = ['runtime', 'executor-prepare', 'response'].includes(command) ? 'host' : isolated ? (command === 'evidence' ? 'host' : 'multi')
295
308
  : definition.domain === 'ios' ? 'ios' : definition.domain === 'web' ? 'web' : 'android';
296
309
  const role = command === 'runtime' || command === 'intent' || command === 'script' || workflowCommands.has(command) ? 'execution'
297
- : command === 'evidence' ? 'evidence' : expertCommands.has(command) ? 'expert' : 'capability';
310
+ : ['evidence', 'response'].includes(command) ? 'evidence' : expertCommands.has(command) ? 'expert' : 'capability';
298
311
  const permission = scriptPermissionByCommand.get(command) || null;
299
312
  return {
300
313
  role, platform,
@@ -316,7 +329,9 @@ function commandContract(command) {
316
329
 
317
330
  function commandSchema(command) {
318
331
  const definition = commandByName.get(command) || isolatedByName.get(command);
319
- if (!definition) throw new CommandError('unknown_command', `Unknown command: ${command}`, { field: 'command' });
332
+ if (!definition) throw unknownCommand(command);
333
+ if (command === 'response') return require('./response-store').responseSchema();
334
+ if (command === 'executor-prepare') return require('./executors/preparation').preparationSchema();
320
335
  if (require('./ui-observation').commands.has(command)) return require('./ui-observation').schema(command, optionTypes);
321
336
  if (command === 'web-executor') return require('./executors/command-schema').webExecutorSchema();
322
337
  if (command === 'android-executor') return require('./executors/command-schema').androidExecutorSchema();
@@ -2,13 +2,77 @@
2
2
 
3
3
  const { CommandError } = require('./command-errors');
4
4
  const { validateCommandArguments } = require('./command-registry');
5
+ const { validateValue } = require('./shared-kernel/argument-schema');
6
+
7
+ // The public run request shared by MCP and the CLI. Business arguments stay
8
+ // under `arguments`; `extract` and `output` never reach a device action.
9
+ const extractSchema = {
10
+ description: 'Required. Use null for this command\'s result within the output budget, or regex/script to extract a smaller result on the Host. Execution and control facts remain separate. On overflow or extraction failure, use a returned response ref to retry extraction; do not repeat the action.',
11
+ oneOf: [
12
+ { type: 'null' },
13
+ {
14
+ type: 'object', additionalProperties: false,
15
+ required: ['mode', 'pattern', 'inputPath'],
16
+ properties: {
17
+ mode: { const: 'regex' },
18
+ pattern: { type: 'string', minLength: 1, maxLength: 2048 },
19
+ flags: { type: 'string', pattern: '^[imsu]*$', description: 'Unique JavaScript RegExp flags. The Host returns all matches; g/y are not accepted.' },
20
+ inputPath: { type: 'string', description: 'JSON Pointer to a string in the response. Empty string selects a text response.' },
21
+ timeoutMs: { type: 'integer', minimum: 1, maximum: 10000 },
22
+ },
23
+ },
24
+ {
25
+ type: 'object', additionalProperties: false,
26
+ required: ['mode', 'language'],
27
+ properties: {
28
+ mode: { const: 'script' },
29
+ language: { enum: ['javascript', 'python'] },
30
+ source: { type: 'string', minLength: 1, maxLength: 65536, description: 'main(ctx). ctx.inputs contains kind, response, execution, control. Return a JSON value.' },
31
+ sourcePath: { type: 'string', minLength: 1, description: 'Resolve from the caller cwd and freeze source before dispatch. Exactly one of source/sourcePath.' },
32
+ timeoutMs: { type: 'integer', minimum: 1, maximum: 10000 },
33
+ },
34
+ oneOf: [{ required: ['source'] }, { required: ['sourcePath'] }],
35
+ },
36
+ ],
37
+ };
38
+ const outputSchema = {
39
+ type: 'object', additionalProperties: false, required: ['maxBytes'],
40
+ properties: { maxBytes: { type: 'integer', minimum: 16384, maximum: 262144 } },
41
+ };
42
+
43
+ function publicRequestSchema() {
44
+ return {
45
+ type: 'object', additionalProperties: false, required: ['command', 'extract'],
46
+ properties: {
47
+ command: { type: 'string', minLength: 1 },
48
+ arguments: { type: 'object', additionalProperties: true },
49
+ extract: extractSchema,
50
+ output: outputSchema,
51
+ },
52
+ };
53
+ }
54
+
55
+ // Preserve a valid caller budget even when another part of the request fails
56
+ // validation. Invalid output is still reported by validateRunRequest.
57
+ function publicOutputLimit(args) {
58
+ if (!args || !Object.hasOwn(args, 'output')) return undefined;
59
+ try { validateValue(args.output, outputSchema, 'output'); }
60
+ catch { return undefined; }
61
+ return args.output.maxBytes;
62
+ }
5
63
 
6
64
  function validateRunRequest(args) {
7
65
  if (!args || typeof args !== 'object' || Array.isArray(args)) throw new CommandError('invalid_argument', 'run requires an object.', { field: 'arguments' });
8
- const extra = Object.keys(args).find(key => key !== 'command' && key !== 'arguments');
9
- if (extra) throw new CommandError('unsupported_argument', 'Put all command parameters in run.arguments.', { field: extra });
66
+ const extra = Object.keys(args).find(key => !['command', 'arguments', 'extract', 'output'].includes(key));
67
+ if (extra) throw new CommandError('unsupported_argument', 'Put all command parameters in run.arguments; only command, arguments, extract and output are top-level fields.', { field: extra });
68
+ if (!Object.hasOwn(args, 'extract')) {
69
+ throw new CommandError('missing_argument', 'extract is required. Pass null (CLI: --extract null) to deliver this command\'s own result, or a regex/script extraction. Nothing was dispatched.', { field: 'extract' });
70
+ }
71
+ validateValue(args.extract, extractSchema, 'extract');
72
+ if (Object.hasOwn(args, 'output')) validateValue(args.output, outputSchema, 'output');
10
73
  const command = typeof args.command === 'string' ? args.command : '';
11
- return { command, arguments: validateCommandArguments(command, args.arguments === undefined ? {} : args.arguments) };
74
+ return { command, arguments: validateCommandArguments(command, args.arguments === undefined ? {} : args.arguments), extract: args.extract,
75
+ ...(args.output ? { output: args.output } : {}) };
12
76
  }
13
77
 
14
- module.exports = { validateRunRequest };
78
+ module.exports = { validateRunRequest, publicRequestSchema, publicOutputLimit, extractSchema, outputSchema };
@@ -1,9 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  const { executeProviderCommand } = require('./device-provider');
4
- const { validateRunRequest } = require('./command-request');
4
+ const { validateRunRequest, publicOutputLimit } = require('./command-request');
5
5
  const { resolveCommandPaths } = require('./shared-kernel/request-context');
6
6
  const { commandFailure, normalizeCommandResult } = require('./command-errors');
7
+ const { publicReply, publicFailure, responseReply, finishReply, boundedReply } = require('./public-reply');
8
+ const { prepareExtraction } = require('./extraction/prepare');
7
9
  const { runExecution, withoutExecution } = require('./shared-kernel/execution-scope');
8
10
  const { getHostFactStore } = require('./shared-kernel/host-fact-store');
9
11
  const { FactRecorder, historyDescriptor, isMobileCaptureCommand } = require('./fact-recorder');
@@ -35,8 +37,9 @@ let closing = false;
35
37
  const activeRuns = new Set();
36
38
 
37
39
  // Callers share one execution host. The owning process controls its lifecycle.
40
+ // Every reply is the public reply of the request, assembled exactly once here.
38
41
  function run(args, dependencies) {
39
- if (closing) return Promise.resolve(valueReply({ ok: false, error: 'runtime_stopping', dispatched: false, ambiguous: false }));
42
+ if (closing) return Promise.resolve({ value: boundedReply(publicReply({ command: requestedCommand(args), reply: valueReply({ ok: false, error: 'runtime_stopping', dispatched: false, ambiguous: false }) })) });
40
43
  const task = runGeneric(args, dependencies);
41
44
  activeRuns.add(task);
42
45
  task.then(() => activeRuns.delete(task), () => activeRuns.delete(task));
@@ -147,11 +150,30 @@ function wrapIsolatedEntry(entry, namespace) {
147
150
  };
148
151
  }
149
152
 
153
+ function requestedCommand(args) {
154
+ return typeof args?.command === 'string' ? args.command : undefined;
155
+ }
156
+
150
157
  async function runGeneric(args = {}, dependencies = {}) {
158
+ const command = requestedCommand(args);
159
+ const maxBytes = publicOutputLimit(args);
160
+ let request;
161
+ let commandArguments;
162
+ let extract;
163
+ try {
164
+ request = validateRunRequest(args);
165
+ commandArguments = resolveCommandPaths(request.command, request.arguments);
166
+ extract = prepareExtraction(request.extract);
167
+ } catch (error) { return { value: publicFailure({ command, stage: 'validation', error, maxBytes }) }; }
151
168
  try {
152
- const request = validateRunRequest(args);
153
- return commandRouter.route(request.command, resolveCommandPaths(request.command, request.arguments), dependencies);
154
- } catch (error) { return valueReply(commandFailure(error, typeof args?.command === 'string' ? args.command : undefined)); }
169
+ const getStore = () => dependencies.responseStore || getResponseStore();
170
+ if (command === 'response') {
171
+ const frozen = getStore().read(commandArguments.ref);
172
+ return { value: await finishReply({ body: responseReply(frozen), extract, output: request.output, frozen }) };
173
+ }
174
+ const reply = await commandRouter.route(request.command, commandArguments, dependencies);
175
+ return { value: await finishReply({ body: publicReply({ command: request.command, reply, completed: true }), extract, output: request.output, getStore }) };
176
+ } catch (error) { return { value: publicFailure({ command: request.command, stage: 'execution', error, maxBytes: request.output?.maxBytes }) }; }
155
177
  }
156
178
 
157
179
  async function runCommand(command, args = {}, dependencies = {}) {
@@ -538,6 +560,10 @@ function getSharedFactStore() {
538
560
  return sharedFactStore;
539
561
  }
540
562
 
563
+ function getResponseStore() {
564
+ return require('./response-store').createResponseStore({ adapter: createSegmentedEvidenceAdapter(getSharedFactStore()) });
565
+ }
566
+
541
567
  function getSharedFactRecorder() {
542
568
  if (String(process.env.AI_APP_BRIDGE_FACT_CACHE || '').toLowerCase() === 'off') return null;
543
569
  if (sharedFactRecorder) return sharedFactRecorder;
@@ -585,6 +611,7 @@ function getSharedObservationCollector(factRecorder) {
585
611
  }
586
612
 
587
613
  async function runRawCommand(command, args = {}) {
614
+ if (command === 'executor-prepare') return require('./executors/preparation').prepareExecutor(args);
588
615
  if (command === 'android-executor') {
589
616
  androidExecutor ||= new (require('./executors/android-host').AndroidExecutorHost)();
590
617
  return androidExecutor.run(args);
@@ -6,6 +6,7 @@ const path = require('node:path');
6
6
  const http = require('node:http');
7
7
  const { randomBytes, randomUUID } = require('node:crypto');
8
8
  const { CommandError, commandFailure } = require('./command-errors');
9
+ const { publicFailure } = require('./public-reply');
9
10
  const { protocol, maxMessageBytes, encodeReply, readJson } = require('./runtime-protocol');
10
11
  const { runtimeLocation, runtimeIdentity, acquireRuntimeLock, publishEndpoint } = require('./runtime-directory');
11
12
  const { inRequestDirectory } = require('./shared-kernel/request-context');
@@ -24,21 +25,28 @@ async function start() {
24
25
  const status = () => ({ ok: true, command: 'runtime', status: phase, runtimeId, pid: process.pid, identity,
25
26
  startedAtMs, facts: location.facts, profile: location.profile, node: { executable: process.execPath, version: process.version } });
26
27
  const server = http.createServer(async (request, response) => {
28
+ // Execute replies are public replies; a failure of the execute path itself
29
+ // is wrapped the same way. Status and stop keep their internal shape.
30
+ let message = null;
31
+ const executing = () => message?.method === 'execute';
32
+ const commandOf = () => (typeof message?.arguments?.command === 'string' ? message.arguments.command : undefined);
27
33
  const send = reply => {
28
34
  if (response.destroyed) return;
29
- let message;
30
- try { message = JSON.stringify({ protocol, runtimeId, reply: encodeReply(reply) }); }
31
- catch (error) { message = JSON.stringify({ protocol, runtimeId, reply: encodeReply({ value: commandFailure(error) }) }); }
32
- if (Buffer.byteLength(message) > maxMessageBytes) message = JSON.stringify({ protocol, runtimeId,
33
- reply: encodeReply({ value: { ok: false, error: 'runtime_message_too_large', dispatched: null, ambiguous: true } }) });
35
+ let body;
36
+ try { body = JSON.stringify({ protocol, runtimeId, reply: encodeReply(reply) }); }
37
+ catch (error) { body = JSON.stringify({ protocol, runtimeId, reply: encodeReply(failure(error)) }); }
38
+ if (Buffer.byteLength(body) > maxMessageBytes) {
39
+ body = JSON.stringify({ protocol, runtimeId, reply: encodeReply(failure(new CommandError('runtime_message_too_large', 'Runtime messages are limited to 128 MiB.', { dispatched: null, ambiguous: true }))) });
40
+ }
34
41
  response.writeHead(200, { 'Content-Type': 'application/json', Connection: 'close' });
35
- response.end(message);
42
+ response.end(body);
36
43
  };
44
+ const failure = error => (executing() ? { value: publicFailure({ command: commandOf(), stage: 'execution', error }) } : { value: commandFailure(error) });
37
45
  try {
38
46
  if (request.headers.authorization !== `Bearer ${token}` || request.method !== 'POST' || request.url !== '/rpc') {
39
47
  request.resume(); throw new CommandError('runtime_access_denied', 'The local runtime connection is not authenticated.');
40
48
  }
41
- const message = await readJson(request);
49
+ message = await readJson(request);
42
50
  if (!message || typeof message !== 'object' || Array.isArray(message)
43
51
  || Object.keys(message).some(key => !['protocol', 'runtimeId', 'method', 'identity', 'arguments', 'cwd'].includes(key))
44
52
  || message.protocol !== protocol || message.runtimeId !== runtimeId) throw new CommandError('runtime_protocol_error', 'Runtime request identity or fields are invalid.');
@@ -46,8 +54,7 @@ async function start() {
46
54
  if (message.method === 'stop') { await stop(); send({ value: status() }); return; }
47
55
  if (message.method !== 'execute') throw new CommandError('runtime_protocol_error', 'Unknown runtime method.');
48
56
  if (phase !== 'running') throw new CommandError('runtime_stopping', 'The runtime is stopping.');
49
- if (message.identity?.code !== identity.code) throw new CommandError('runtime_code_mismatch', 'The runtime and client use different code.');
50
- if (message.identity?.config !== identity.config) throw new CommandError('runtime_configuration_mismatch', 'The runtime and client use different configuration.');
57
+ require('./runtime-directory').requireCompatible({ identity, runtimeId, pid: process.pid }, message.identity);
51
58
  let validDirectory = false;
52
59
  if (typeof message.cwd === 'string' && path.isAbsolute(message.cwd)) {
53
60
  try { validDirectory = fs.statSync(message.cwd).isDirectory(); }
@@ -57,7 +64,7 @@ async function start() {
57
64
  // Closing this HTTP connection never cancels the admitted work. Its
58
65
  // original runtime, deadline and explicit cancel operation own settlement.
59
66
  send(await inRequestDirectory(message.cwd, () => host.run(message.arguments)));
60
- } catch (error) { send({ value: commandFailure(error) }); }
67
+ } catch (error) { send(failure(error)); }
61
68
  });
62
69
  server.requestTimeout = 30000;
63
70
  server.headersTimeout = 10000;