@mobileaidev/ai-app-bridge 0.4.0 → 0.4.2

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
@@ -7,7 +7,9 @@ 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.4.0`; registry publication is a separate release step.
10
+ This source version is `0.4.2`; registry publication is a separate release step.
11
+ It fixes iOS observation ownership and preserves extraction results when
12
+ temporary cleanup fails. Device and Web SDKs share version `0.4.2`; their execution contracts are unchanged.
11
13
  The default installation includes the Script/Intent and capture contracts below.
12
14
  Local package verification does not change npm dist-tags.
13
15
  The supported Node range is `>=26.3.0 <27`; this release was checked on 26.3.0.
@@ -65,7 +67,7 @@ domains, commands, and options, then call `run` with the selected command.
65
67
 
66
68
  ```bash
67
69
  # Install the current stable release; see docs/RELEASE.md for packaging.
68
- npm install -g @mobileaidev/ai-app-bridge@0.4.0
70
+ npm install -g @mobileaidev/ai-app-bridge@0.4.2
69
71
 
70
72
  ai-app-bridge status --extract null --package-name io.github.mobileaidev.aiappbridge.sample
71
73
  ai-app-bridge tree --extract null --package-name io.github.mobileaidev.aiappbridge.sample
@@ -339,6 +341,12 @@ recovery. `uia-runtime --serial DEVICE --operation status|start|stop` controls
339
341
  its lifecycle. Fresh observation rotates a full, durably acknowledged session;
340
342
  startup retires only confirmed history. Explicit start checks the phone's
341
343
  process lock before reopening a dead runtime; unknown actions still block it.
344
+ For a stuck action or agent handoff, run `ai-app-bridge device-ownership
345
+ --operation force-stop --serial DEVICE --extract null`. This independent reset
346
+ stops old Host tasks and phone executors, archives the unknown action, and frees
347
+ occupancy without waiting for its receipt. Tasks sharing those Host processes
348
+ also stop. New operations can then start; dispatched effects are not rolled back.
349
+ Occupancy errors include the same recovery hint.
342
350
  Host crashes leave a durable pending-acknowledgement queue. `device-ownership
343
351
  --operation reconcile --serial DEVICE` commits completion history, acknowledges
344
352
  live or stopped phone records and retires that queue without replay.
@@ -17,7 +17,7 @@ const commandDefinitions = [
17
17
  { command: 'flutter-executor', domain: 'advanced', summary: 'Optional Flutter integration_test executor on Android. Open the installed application test entrypoint, observe widgets, run WidgetTester actions, query receipts and close the test process. Requires app.test in Script.', targetKind: 'android-app', options: ['operation'] },
18
18
  { command: 'web-executor', domain: 'web', summary: 'Optional Playwright executor: inspect readiness, prepare pinned browser dependencies, open a browser, observe bound frames, act, wait, query original receipts, or close. Browser input and test operations retain their actual mechanisms. Script requires app.test.', targetKind: 'web-target', options: ['operation'] },
19
19
  { command: 'runtime', domain: 'execution', summary: 'Inspect, start or orderly stop the shared local execution runtime. CLI exit and MCP disconnect leave operations running; stop cancels and drains them.', targetKind: 'host-runtime', options: ['operation'] },
20
- { command: 'device-ownership', domain: 'execution', summary: 'Read ownership, reconcile original completion, explicitly cancel a retained install by actionId, or read a retained UIA receipt by serial/runtimeEpoch/actionId. Installation cancellation abandons its original PM session; it does not roll back an installed APK.', targetKind: 'android-device', options: ['operation', 'serial', 'timeoutMs', 'runtimeEpoch', 'actionId'] },
20
+ { command: 'device-ownership', domain: 'execution', summary: 'Read ownership, reconcile completion, or force-stop stuck tasks and reset device occupancy without requiring old receipts. Force-stop terminates the owning and shared Host runtimes, resets phone UIA history, and stops recorded SDK apps; dispatched effects keep an unknown outcome. Also cancel-install or read a retained UIA receipt.', targetKind: 'android-device', options: ['operation', 'serial', 'timeoutMs', 'runtimeEpoch', 'actionId'] },
21
21
  { command: 'uia-runtime', domain: 'advanced', summary: 'Read, start or orderly stop the Android API 25+ UIA node runtime. Start checks the phone process lock; unacknowledged original receipts are retained.', targetKind: 'android-device', options: ['operation', 'serial', 'adb', 'timeoutMs'] },
22
22
  { command: 'status', domain: 'core', summary: 'Read bridge status, app/device metadata, capture counts, and Flutter summary.', targetApp: true, options: ['packageName', 'port', 'serial', 'full'] },
23
23
  { command: 'tree', domain: 'core', summary: 'Read Android View tree from the in-app bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes', 'maxDepth'] },
@@ -245,12 +245,14 @@ const commandByName = new Map(commandDefinitions.map(d => [d.command, d]));
245
245
  const isolatedByName = new Map(isolatedCommandDefinitions.map(d => [d.command, d]));
246
246
 
247
247
  function isMutationCommand(command, args = {}) {
248
+ // iOS observation controls only a bounded SDK capture lease, not a UI action.
249
+ if (command === 'ios-ui-observation') return false;
248
250
  if (require('./ui-observation').commands.has(command)) return args.operation !== 'status';
249
251
  if (['android-executor', 'flutter-executor'].includes(command)) return ['open', 'act', 'close'].includes(args.operation);
250
252
  if (command === 'web-executor') return ['prepare', 'open', 'act', 'navigate', 'close'].includes(args.operation);
251
253
  if (command === 'web-command' && args.name === 'domSnapshot') return false;
252
254
  return mutationCommands.has(command) || (command === 'logcat' && args.clear === true)
253
- || (command === 'device-ownership' && args.operation === 'cancel-install')
255
+ || (command === 'device-ownership' && ['cancel-install', 'force-stop'].includes(args.operation))
254
256
  || (command === 'ios-wda-session' && args.operation !== 'status')
255
257
  || (command === 'uia-runtime' && args.operation !== 'status')
256
258
  || ((command === 'webview-network' || command === 'webview-console') && args.script !== undefined);
@@ -319,7 +321,7 @@ function commandContract(command) {
319
321
  providersByPlatform: { android: ['native', 'uia', 'flutter', 'h5'], ios: ['native', 'h5', 'flutter'], web: ['h5'] } } : {}),
320
322
  execution: { kind: role === 'execution' ? 'operation' : isMutationCommand(command) ? 'mutation' : 'query',
321
323
  mutation: isMutationCommand(command),
322
- conditionalMutation: command === 'web-executor' ? 'operation in prepare,open,act,navigate,close' : ['android-executor', 'flutter-executor'].includes(command) ? 'operation in open,act,close' : command === 'uia-runtime' ? 'operation != status' : command === 'logcat' ? 'clear=true' : ['webview-network', 'webview-console'].includes(command) ? 'script is supplied' : null,
324
+ conditionalMutation: command === 'device-ownership' ? 'operation in reconcile,cancel-install,force-stop' : command === 'web-executor' ? 'operation in prepare,open,act,navigate,close' : ['android-executor', 'flutter-executor'].includes(command) ? 'operation in open,act,close' : command === 'uia-runtime' ? 'operation != status' : command === 'logcat' ? 'clear=true' : ['webview-network', 'webview-console'].includes(command) ? 'script is supplied' : null,
323
325
  arbitration: command === 'script' || command === 'intent' ? 'target-platform-physical-device'
324
326
  : isAndroidMutation(command) ? 'cross-process-physical-android-device'
325
327
  : platform === 'ios' && isMutationCommand(command) ? 'cross-process-physical-ios-device'
@@ -340,11 +342,11 @@ function commandSchema(command) {
340
342
  properties: { operation: { enum: ['start', 'status', 'stop'] } } };
341
343
  if (definition.domain === 'web') return require('./web/command-schema').webSchema(command);
342
344
  if (command === 'device-ownership') return { type: 'object', additionalProperties: false,
343
- properties: { operation: { enum: ['status', 'reconcile', 'receipt', 'cancel-install'] }, serial: { type: 'string', minLength: 1 },
345
+ properties: { operation: { enum: ['status', 'reconcile', 'receipt', 'cancel-install', 'force-stop'] }, serial: { type: 'string', minLength: 1 },
344
346
  runtimeEpoch: { type: 'string', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' },
345
347
  actionId: { type: 'string', minLength: 1, maxLength: 1024 }, timeoutMs: { type: 'integer', minimum: 1, maximum: 30000 } },
346
348
  required: ['operation', 'serial'], oneOf: [
347
- { properties: { operation: { enum: ['status', 'reconcile'] }, runtimeEpoch: false, actionId: false } },
349
+ { properties: { operation: { enum: ['status', 'reconcile', 'force-stop'] }, runtimeEpoch: false, actionId: false } },
348
350
  { properties: { operation: { const: 'receipt' }, timeoutMs: false }, required: ['runtimeEpoch', 'actionId'] },
349
351
  { properties: { operation: { const: 'cancel-install' }, runtimeEpoch: false }, required: ['actionId'] },
350
352
  ] };
@@ -8,8 +8,19 @@ const { CommandError } = require('../command-errors');
8
8
  const { atomicJson, readJson } = require('./managed-runtime');
9
9
  const { AndroidExecutorPort } = require('./android-port');
10
10
 
11
- const fileFor = serial => path.join(defaultDirectory(), 'automation-sessions', createHash('sha256').update(serial).digest('hex') + '.json');
12
- function owner(serial) { return readJson(fileFor(serial)); }
11
+ const fileFor = (serial, directory = defaultDirectory()) => path.join(directory, 'automation-sessions', createHash('sha256').update(serial).digest('hex') + '.json');
12
+ function owner(serial, directory) { return readJson(fileFor(serial, directory)); }
13
+ function archive(serial, resetId, directory = defaultDirectory()) {
14
+ const file = fileFor(serial, directory);
15
+ if (!fs.existsSync(file)) return null;
16
+ const destination = path.join(directory, 'force-stopped', `${resetId}.automation.json`);
17
+ fs.renameSync(file, destination);
18
+ for (const parent of [path.dirname(file), path.dirname(destination)]) {
19
+ const fd = fs.openSync(parent, 'r');
20
+ try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
21
+ }
22
+ return destination;
23
+ }
13
24
  function claim(serial, descriptorFile) {
14
25
  const descriptor = readJson(descriptorFile);
15
26
  if (!descriptor || descriptor.serial !== serial) throw new CommandError('executor_descriptor_mismatch', 'Automation claim requires the exact device descriptor.');
@@ -35,8 +46,8 @@ async function assertAvailable(serial) {
35
46
  const runner = readJson(path.join(path.dirname(current.descriptorFile), 'runner-result.json'));
36
47
  if ((runner?.sessionId === current.sessionId && runner.instrumentFinished)
37
48
  || await port.bootChanged() || (descriptor.pid && await port.processEnded())) { release(serial, current.sessionId); return; }
38
- throw new CommandError('uia_owned_by_test_executor', 'The test session owns UiAutomation. Use android-executor with engine uiautomator, or close that session first.',
49
+ throw new CommandError('uia_owned_by_test_executor', 'The test session owns UiAutomation. Close that session, or use device-ownership force-stop to stop stuck tasks and reset this device.',
39
50
  { dispatched: false, ambiguous: false, details: { serial, sessionId: current.sessionId, runtimeEpoch: descriptor.runtimeEpoch, packageName: descriptor.packageName } });
40
51
  }
41
52
 
42
- module.exports = { owner, claim, release, assertAvailable };
53
+ module.exports = { owner, claim, release, archive, assertAvailable };
@@ -66,7 +66,7 @@ async function runExtraction(prepared, inputs) {
66
66
  // every descendant to close its copies of stdout/stderr.
67
67
  child?.stdin?.destroy(); child?.stdout?.destroy(); child?.stderr?.destroy();
68
68
  try { if (directory) fs.rmSync(directory, { recursive: true, force: true }); }
69
- catch (error) { result = { ok: false, error: 'extraction_cleanup_failed', message: error.message }; }
69
+ catch (error) { result.cleanupError = error.code || 'extraction_cleanup_failed'; }
70
70
  finally { active--; }
71
71
  }
72
72
  const diagnostics = channel?.diagnostics();
@@ -93,6 +93,12 @@ async function reconcileIOS({ lease, device, args, createPort, readWdaTestSummar
93
93
  } catch (error) { return { settled: false, error: 'ios_wda_startup_completion_unavailable', cause: error.code || 'invalid_result' }; }
94
94
  }
95
95
  if (args.setupResultPath) return { settled: false, error: 'ios_original_completion_identity_required' };
96
+ if (pending.kind === 'ios-command' && pending.command === 'ios-ui-observation') {
97
+ // Older Hosts incorrectly journaled bounded capture control as a device
98
+ // action. Retire only that marker; its observation outcome remains unknown.
99
+ return { kind: 'ios-ui-observation', settled: true, dispatched: null,
100
+ reason: 'observation_control_not_device_mutation', observationOutcome: 'unknown' };
101
+ }
96
102
  if (pending.kind === 'ios-command') {
97
103
  const invocation = pending.invocation;
98
104
  const index = Array.isArray(invocation?.arguments) ? invocation.arguments.indexOf('--json-output') : -1;
@@ -96,7 +96,7 @@ class IOSBridgeProvider {
96
96
  case 'ios-status':
97
97
  return await this.runtimeGet(args, '/v1/status');
98
98
  case 'ios-ui-observation': {
99
- const port = await this.runtimePort(args, context);
99
+ const port = await this.runtimePort(args, { ...context, mutation: false });
100
100
  const result = await port.post(require('./ui-observation').path(args), require('./ui-observation').request(args));
101
101
  return { ...result, endpoint: port.endpoint.baseUrl, device: port.endpoint.device, runtimeBinding: port.endpoint.runtimeBinding };
102
102
  }
@@ -445,7 +445,8 @@ class IOSBridgeProvider {
445
445
 
446
446
  async runtimePort(args, options = {}) {
447
447
  const endpoint = await this.resolveRuntimeEndpoint(args, options);
448
- const httpOptions = { timeoutMs: this.context(args).httpTimeoutMs, headers: bindingHeaders(endpoint.runtimeBinding) };
448
+ const httpOptions = { timeoutMs: this.context(args).httpTimeoutMs, headers: bindingHeaders(endpoint.runtimeBinding),
449
+ ...(options.mutation === false ? { mutation: false } : {}) };
449
450
  const request = async (method, endpointPath, payload) => {
450
451
  let response;
451
452
  try {
@@ -137,7 +137,8 @@ async function run(request, { signal } = {}) {
137
137
  let extract;
138
138
  try {
139
139
  request = validateRunRequest(request);
140
- if (request.command === 'runtime' || (request.command === 'evidence' && request.arguments.operation === 'verify')) {
140
+ if (request.command === 'runtime' || (request.command === 'evidence' && request.arguments.operation === 'verify')
141
+ || (request.command === 'device-ownership' && request.arguments.operation === 'force-stop')) {
141
142
  extract = require('./extraction/prepare').prepareExtraction(request.extract);
142
143
  }
143
144
  }
@@ -145,6 +146,9 @@ async function run(request, { signal } = {}) {
145
146
  const local = async reply => ({ value: await finishReply({ body: publicReply({ command: request.command, reply, completed: true }),
146
147
  extract, output: request.output }) });
147
148
  try {
149
+ if (request.command === 'device-ownership' && request.arguments.operation === 'force-stop') {
150
+ return local({ value: await require('./shared-kernel/device-force-stop').forceStopDevice(request.arguments) });
151
+ }
148
152
  // Verification is an offline command in both transports; it neither
149
153
  // opens FactStore nor depends on a running owner or valid store profile.
150
154
  if (request.command === 'evidence' && request.arguments.operation === 'verify') {
@@ -80,7 +80,7 @@ function requireCompatible(status, client) {
80
80
  let next;
81
81
  if (!codeMismatch) next = 'Use matching persistent/provider configuration; the running Runtime was not restarted.';
82
82
  else if (outdatedSide === 'client') next = 'The client package is older. Update and reconnect the client; do not stop the newer Runtime to fix this client.';
83
- else if (outdatedSide === 'runtime') next = 'The Runtime package is older. Finish its active tasks, then explicitly run runtime --operation stop --extract null and reconnect with the updated installation.';
83
+ else if (outdatedSide === 'runtime') next = 'The Runtime package is older. Finish its active tasks, then explicitly run runtime --operation stop --extract null and reconnect with the updated installation. If a device task is stuck, device-ownership --operation force-stop --serial <serial> --extract null forcibly stops old tasks and resets occupancy.';
84
84
  else next = 'Builds or Node environments differ. Align the installations and reconnect; fingerprints alone cannot identify an older side.';
85
85
  throw new CommandError(codeMismatch ? 'runtime_code_mismatch' : 'runtime_configuration_mismatch',
86
86
  `Client: ${describe(client)}. Runtime: ${describe(runtime)}. ${next}`,
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { randomUUID } = require('node:crypto');
6
+ const { CommandError } = require('../command-errors');
7
+ const { createOwnershipStore, schemaVersion } = require('./device-ownership-store');
8
+ const { stopHosts } = require('./host-process-stop');
9
+
10
+ async function stopDevice(serial, pending, timeoutMs, automation) {
11
+ const { readJson } = require('../executors/managed-runtime');
12
+ const descriptor = automation ? readJson(automation.descriptorFile) : null;
13
+ if (descriptor && (descriptor.serial !== serial || descriptor.sessionId !== automation.sessionId))
14
+ throw new CommandError('executor_descriptor_mismatch', 'The recorded test session does not match this device.');
15
+ const { executablePath } = require('./executable-path');
16
+ const adb = pending.find(item => item.target?.adb)?.target.adb || descriptor?.adb || executablePath(process.env.ADB || 'adb');
17
+ const { createUiaRuntimePort } = require('./uia-runtime-port');
18
+ const uia = await createUiaRuntimePort({ adb, serial, timeoutMs }).forceStop();
19
+ const packages = [...new Set([...pending.map(item => item.target?.packageName || item.packageName),
20
+ descriptor?.packageName, descriptor?.instrumentation?.split('/')[0]].filter(Boolean))];
21
+ const { execFileBounded } = require('./execution-io');
22
+ for (const packageName of packages) {
23
+ if (!/^[a-zA-Z0-9_.]+$/.test(packageName)) throw new CommandError('invalid_force_stop_package', 'The recorded Android package is invalid.');
24
+ await execFileBounded(adb, ['-s', serial, 'shell', 'am', 'force-stop', packageName], { timeoutMs, mutation: false, encoding: 'utf8' });
25
+ }
26
+ return { ...uia, stoppedPackages: packages };
27
+ }
28
+
29
+ // This control runs in the CLI/MCP client, before contacting the shared runtime.
30
+ // A hung runtime, its queues, and version mismatch cannot intercept the reset.
31
+ async function forceStopDevice(args, { directory, stopHost = stopHosts, stopDevice: remoteStop = stopDevice,
32
+ runtimeEndpoint } = {}) {
33
+ const store = createOwnershipStore(directory);
34
+ const control = store.controlLock(args.serial);
35
+ if (!control) throw new CommandError('device_reset_in_progress', 'Another force-stop is resetting this device. Retry after it finishes.');
36
+ let lock;
37
+ let journalError = null;
38
+ const readForReset = () => {
39
+ try { return store.read(args.serial); }
40
+ catch (error) {
41
+ if (error.code !== 'device_ownership_corrupt') throw error;
42
+ // Forced reset archives corrupt bytes instead of requiring the damaged
43
+ // task record to prove completion. Normal admission remains strict.
44
+ journalError = error.code; return null;
45
+ }
46
+ };
47
+ try {
48
+ let previous = readForReset();
49
+ if (runtimeEndpoint === undefined) {
50
+ const { runtimeLocation, readEndpoint } = require('../runtime-directory');
51
+ runtimeEndpoint = readEndpoint(runtimeLocation());
52
+ }
53
+ const stoppedHostPids = await stopHost(previous?.owner, runtimeEndpoint);
54
+ const deadline = Date.now() + (args.timeoutMs ?? 10000);
55
+ while (!(lock = store.lock(args.serial))) {
56
+ if (Date.now() >= deadline) throw new CommandError('force_stop_host_still_owned', 'The terminated Host has not released its OS lock.');
57
+ await new Promise(resolve => setTimeout(resolve, 25));
58
+ }
59
+ const resetId = randomUUID(), resetAtMs = Date.now();
60
+ previous = readForReset();
61
+ const pending = previous?.resetPending || [previous?.pending, ...(previous?.reservations || [])].filter(Boolean);
62
+ const abandoned = pending.map(item => ({ kind: item.kind, actionId: item.actionId ?? null, runtimeEpoch: item.runtimeEpoch ?? null }));
63
+ const archiveDir = path.join(store.directory, 'force-stopped');
64
+ fs.mkdirSync(archiveDir, { recursive: true, mode: 0o700 });
65
+ const archivePath = fs.existsSync(store.paths(args.serial).journal) ? path.join(archiveDir, `${resetId}.json`) : null;
66
+ if (archivePath) {
67
+ const file = fs.openSync(archivePath, 'wx', 0o600);
68
+ try { fs.writeFileSync(file, fs.readFileSync(store.paths(args.serial).journal)); fs.fsyncSync(file); }
69
+ finally { fs.closeSync(file); }
70
+ const parent = fs.openSync(archiveDir, 'r');
71
+ try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); }
72
+ }
73
+ const state = { schemaVersion, serial: args.serial, phase: 'idle', owner: null, pending: null, reservations: [],
74
+ pendingAcknowledgements: [], lastSettlement: previous?.lastSettlement ?? null, resetRequired: true, resetPending: pending,
75
+ lastReset: { resetId, resetAtMs, disposition: 'force_stopped', outcome: 'unknown', abandoned, archivePath, stoppedHostPids,
76
+ ...(journalError ? { journalError } : {}) } };
77
+ store.write(args.serial, state);
78
+ const automationStore = require('../executors/automation-owner');
79
+ let phone;
80
+ try {
81
+ const automation = automationStore.owner(args.serial, store.directory);
82
+ phone = await remoteStop(args.serial, pending, args.timeoutMs ?? 10000, automation);
83
+ if (phone?.ok !== true) throw new CommandError(phone?.error || 'force_stop_device_failed', 'The phone did not confirm the forced reset.');
84
+ if (automation) state.lastReset.automationArchivePath = automationStore.archive(args.serial, resetId, store.directory);
85
+ } catch (error) {
86
+ const code = typeof error.code === 'string' && /^[a-z][a-z0-9_]*$/.test(error.code) ? error.code : 'force_stop_device_failed';
87
+ return { ok: false, error: code, phase: 'idle', serial: args.serial,
88
+ ownershipReleased: true, remoteResetPending: true, ...state.lastReset,
89
+ remoteError: { code, message: error.message },
90
+ message: 'Local occupancy was reset. Reconnect the device and repeat device-ownership force-stop before new actions.' };
91
+ }
92
+ state.resetRequired = false; delete state.resetPending; state.lastReset.phone = phone; store.write(args.serial, state);
93
+ return { ok: true, serial: args.serial, phase: 'idle', ownershipReleased: true, remoteResetPending: false,
94
+ ...state.lastReset, phone, message: 'Forced reset completed. Old tasks were stopped; agents can start new operations.' };
95
+ } finally { lock?.close(); control.close(); }
96
+ }
97
+
98
+ module.exports = { forceStopDevice };
@@ -9,10 +9,15 @@ const acknowledgements = require('./device-acknowledgements');
9
9
  const context = new AsyncLocalStorage();
10
10
  const effectContext = new AsyncLocalStorage();
11
11
 
12
+ const recoveryHint = serial => ({ command: 'device-ownership', arguments: { operation: 'force-stop', serial },
13
+ message: 'If stuck, force-stop this device to cancel old tasks and reset occupancy. Already dispatched effects are not rolled back.' });
14
+
12
15
  function rejection(error, serial, ownership) {
13
16
  return { ok: false, error, serial, active: 1, dispatched: false, ambiguous: false,
14
17
  message: error === 'target_busy' ? 'Another Host operation owns this physical device.'
18
+ : error === 'device_reset_required' ? 'Phone reset is incomplete. Reconnect and repeat device-ownership force-stop before new actions.'
15
19
  : 'A previous device action has no confirmed completion. Reconcile device ownership before another mutation.',
20
+ recoveryHint: recoveryHint(serial),
16
21
  ...(ownership ? { ownership: structuredClone(ownership) } : {}) };
17
22
  }
18
23
 
@@ -39,13 +44,19 @@ function createDeviceMutationLease({ directory } = {}) {
39
44
  }, release() {} };
40
45
  }
41
46
  if (held.has(serial)) return rejection('target_busy', serial, held.get(serial).state);
42
- const lock = store.lock(serial);
43
- if (!lock) return rejection('target_busy', serial);
47
+ const control = store.controlLock(serial);
48
+ if (!control) return rejection('target_busy', serial);
49
+ let lock;
44
50
  try {
51
+ lock = store.lock(serial);
52
+ if (!lock) return rejection('target_busy', serial);
45
53
  const previous = store.read(serial);
54
+ if (previous?.resetRequired) { lock.close(); return rejection('device_reset_required', serial, previous); }
46
55
  if (previous?.pending || previous?.reservations.length) { lock.close(); return rejection('device_ownership_unresolved', serial, previous); }
47
- const state = { schemaVersion, serial, phase: 'owned', owner: { id: randomUUID(), pid: process.pid, acquiredAtMs: Date.now() },
56
+ const state = { schemaVersion, serial, phase: 'owned', owner: { id: randomUUID(), pid: process.pid,
57
+ processStart: require('./host-process-stop').processStart(), acquiredAtMs: Date.now() },
48
58
  pending: null, reservations: [], lastSettlement: previous?.lastSettlement ?? null,
59
+ lastReset: previous?.lastReset ?? null,
49
60
  pendingAcknowledgements: previous?.pendingAcknowledgements ?? [] };
50
61
  store.write(serial, state);
51
62
  const owner = { serial, directory: store.directory, state, closed: false,
@@ -94,7 +105,8 @@ function createDeviceMutationLease({ directory } = {}) {
94
105
  finally { owner.closed = true; held.delete(serial); lock.close(); }
95
106
  },
96
107
  };
97
- } catch (error) { lock.close(); throw error; }
108
+ } catch (error) { lock?.close(); throw error; }
109
+ finally { control.close(); }
98
110
  }
99
111
  async function run(serial, action) {
100
112
  const token = acquire(serial);
@@ -108,11 +120,16 @@ function createDeviceMutationLease({ directory } = {}) {
108
120
  const state = store.read(serial);
109
121
  const unresolved = state?.pending || state?.reservations.length;
110
122
  return { ok: true, serial, active: !lock || unresolved ? 1 : 0, maxActive: 1,
111
- phase: !lock ? 'owned' : unresolved ? 'unresolved' : 'idle', ownership: state ? structuredClone(state) : null };
123
+ phase: !lock ? 'owned' : unresolved ? 'unresolved' : 'idle', ownership: state ? structuredClone(state) : null,
124
+ ...(unresolved || state?.resetRequired ? { recoveryHint: recoveryHint(serial) } : {}) };
112
125
  } finally { lock?.close(); }
113
126
  }
114
127
  async function reconcile(serial, verify) {
115
- const lock = held.has(serial) ? null : store.lock(serial);
128
+ const control = store.controlLock(serial);
129
+ if (!control) return rejection('target_busy', serial);
130
+ let lock;
131
+ try { lock = held.has(serial) ? null : store.lock(serial); }
132
+ finally { control.close(); }
116
133
  if (!lock) return rejection('target_busy', serial);
117
134
  try {
118
135
  const state = store.read(serial);
@@ -132,7 +149,11 @@ function createDeviceMutationLease({ directory } = {}) {
132
149
  } finally { lock.close(); }
133
150
  }
134
151
  async function drainAcknowledgements(serial, acknowledge) {
135
- const lock = held.has(serial) ? null : store.lock(serial);
152
+ const control = store.controlLock(serial);
153
+ if (!control) return rejection('target_busy', serial);
154
+ let lock;
155
+ try { lock = held.has(serial) ? null : store.lock(serial); }
156
+ finally { control.close(); }
136
157
  if (!lock) return rejection('target_busy', serial);
137
158
  try {
138
159
  const state = store.read(serial), errors = [], retired = [];
@@ -88,7 +88,8 @@ function createOwnershipStore(directory = defaultDirectory()) {
88
88
  throw new CommandError('device_ownership_unavailable', 'Cannot commit device ownership; no new action is allowed.', { details: { serial, cause: error.message } });
89
89
  }
90
90
  }
91
- return { directory, read, lock, write };
91
+ const controlLock = serial => createOwnershipStore(path.join(directory, 'reset-controls')).lock(serial);
92
+ return { directory, read, lock, write, paths, controlLock };
92
93
  }
93
94
 
94
95
  module.exports = { createOwnershipStore, defaultDirectory, schemaVersion };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+ const { CommandError } = require('../command-errors');
5
+
6
+ function processInfo(pid) {
7
+ if (!Number.isSafeInteger(pid) || pid < 1) return null;
8
+ try {
9
+ if (process.platform === 'win32') {
10
+ const raw = execFileSync('powershell.exe', ['-NoProfile', '-Command',
11
+ `$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; if($p){@{start=$p.CreationDate.ToUniversalTime().ToString('O');command=$p.CommandLine}|ConvertTo-Json -Compress}`],
12
+ { encoding: 'utf8', timeout: 3000, windowsHide: true });
13
+ return raw.trim() ? JSON.parse(raw) : null;
14
+ }
15
+ const raw = execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'pgid=', '-o', 'args='],
16
+ { encoding: 'utf8', timeout: 3000, env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'pipe'] });
17
+ const match = raw.trim().match(/^(\w{3}\s+\w{3}\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+(\d+)\s+([\s\S]+)$/);
18
+ return match ? { start: match[1], group: Number(match[2]), command: match[3] } : null;
19
+ } catch (error) {
20
+ if (error.status === 1) return null;
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ let ownStart;
26
+ function processStart() { return ownStart ||= processInfo(process.pid)?.start; }
27
+
28
+ async function stopHosts(owner, endpoint) {
29
+ const stopped = [];
30
+ for (const item of [owner, endpoint]) {
31
+ if (!item || stopped.includes(item.pid)) continue;
32
+ const info = processInfo(item.pid);
33
+ if (!info) continue;
34
+ if (item.pid === process.pid) throw new CommandError('force_stop_requires_control_client',
35
+ 'Run device-ownership force-stop through CLI or MCP, outside the execution owner.');
36
+ const identified = item.processStart ? item.processStart === info.start
37
+ : /[\\/]bin[\\/](execution-runtime|ai-app-bridge|mcp-server)\.js(?:\s|$)/.test(info.command);
38
+ // PID reuse means the original Host is gone. It must neither block reset
39
+ // nor cause an unrelated replacement process to be killed.
40
+ if (!identified) continue;
41
+ try {
42
+ if (process.platform === 'win32') execFileSync('taskkill.exe', ['/PID', String(item.pid), '/F', '/T'], { timeout: 5000, windowsHide: true, stdio: 'pipe' });
43
+ else if (info.group === item.pid) process.kill(-item.pid, 'SIGKILL');
44
+ else {
45
+ const inventory = execFileSync('ps', ['-axo', 'pid=', '-o', 'ppid='],
46
+ { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] });
47
+ const pairs = inventory.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number));
48
+ const descendants = [item.pid];
49
+ for (let i = 0; i < descendants.length; i++)
50
+ for (const [pid, parent] of pairs) if (parent === descendants[i]) descendants.push(pid);
51
+ for (const pid of descendants.reverse()) {
52
+ try { process.kill(pid, 'SIGKILL'); }
53
+ catch (error) { if (error.code !== 'ESRCH') throw error; }
54
+ }
55
+ }
56
+ stopped.push(item.pid);
57
+ } catch (error) { if (error.code !== 'ESRCH') throw error; }
58
+ }
59
+ return stopped;
60
+ }
61
+
62
+ module.exports = { processStart, stopHosts };
@@ -294,6 +294,25 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
294
294
  }
295
295
 
296
296
  return {
297
+ async forceStop() {
298
+ // Host reset owns the separate administrative gate and has already
299
+ // terminated previous Host writers. This never waits for old callbacks.
300
+ const asset = bundle(), destination = await installAsset(asset);
301
+ let raw;
302
+ try { raw = await shell(`CLASSPATH=${quote(destination)} app_process /system/bin ${mainClass} ${quote(root)} ${asset.manifest.sha256} force-reset`); }
303
+ catch (error) {
304
+ let remote;
305
+ try { remote = JSON.parse(error.stderr?.trim()); } catch { /* non-protocol ADB failure */ }
306
+ if (typeof remote?.error === 'string') throw failure(remote.error, remote.message || 'The phone rejected forced reset.');
307
+ throw error;
308
+ }
309
+ let result;
310
+ try { result = JSON.parse(raw); }
311
+ catch { throw failure('uia_force_reset_invalid', 'The phone did not return a forced-reset receipt.'); }
312
+ if (result?.ok !== true || result.schemaVersion !== 'aab.uia.reset.v1' || result.stopped !== true)
313
+ throw failure(result?.error || 'uia_force_reset_unconfirmed', 'The UIA executor was not force-reset.');
314
+ return result;
315
+ },
297
316
  async withInstrumentation(descriptorFile, start) {
298
317
  return withConnectionLock(async () => {
299
318
  const automation = require('../executors/automation-owner');
@@ -710,8 +710,8 @@ the local ADB process does not by itself confirm a cancelled install has settled
710
710
  Use `device-ownership {operation:"status",serial:"DEVICE"}` to inspect ownership,
711
711
  or `operation:"reconcile"` to query the recorded action through its original
712
712
  package and transport configuration. Reconciliation exclusively owns the device
713
- while checking; there is no force-release, caller-supplied replacement target,
714
- expiry, or action replay. Android Native, Flutter, H5 and managed shell actions can
713
+ while checking; normal reconciliation does not expire or replay actions, or
714
+ substitute their target. Android Native, Flutter, H5 and managed shell actions can
715
715
  recover from an identity-matching `aab.native-execution/v1`,
716
716
  `aab.flutter-execution/v1`, `aab.h5-execution/v1` or
717
717
  `aab.android-shell-execution/v1` terminal receipt. UIA node actions use
@@ -725,6 +725,30 @@ Other transports without a matching completion protocol remain unresolved.
725
725
  Installation uses the original phone job and PackageInstaller session contract
726
726
  described below.
727
727
 
728
+ **Stuck device / agent handoff:** use `device-ownership
729
+ {operation:"force-stop",serial:"DEVICE"}`. CLI: `ai-app-bridge device-ownership
730
+ --operation force-stop --serial DEVICE --extract null`. This emergency reset runs
731
+ in the CLI/MCP client before connecting to the execution Runtime, including when
732
+ the Runtime hangs, its queue is blocked, or its version differs. It kills the
733
+ recorded owning Host and the shared Host runtime, including Script/Intent child
734
+ processes and queued tasks. Other tasks in those Host processes also stop.
735
+ On the selected phone it kills UIA and managed shell worker groups, archives UIA
736
+ sessions while retaining the process-lock inode, and stops recorded SDK apps.
737
+ A retained instrumentation/Flutter test owner is stopped and its UiAutomation
738
+ claim archived, so a replacement agent can observe the phone again.
739
+ A new observation starts a fresh UIA epoch. No terminal callback is required.
740
+
741
+ The ownership journal becomes idle. `disposition:force_stopped,outcome:unknown`
742
+ preserves abandoned action identities and the original archived journal; this
743
+ does not roll back dispatched effects or fabricate the original outcome.
744
+ Even a corrupt Host task journal is archived as bytes and reset explicitly.
745
+ Device admission and force-stop share an independent administrative gate.
746
+ If the phone cannot reset, local tasks and occupancy still stop; the reply has
747
+ `ownershipReleased:true,remoteResetPending:true`. Reconnect and repeat force-stop.
748
+ A durable reset-required marker prevents new dispatch until phone reset succeeds,
749
+ including after a reset-client crash. Occupancy failures include a short
750
+ `recoveryHint` with exact force-stop arguments. Resetting an idle device is valid.
751
+
728
752
  The ownership journal is `aab.device-ownership/v2`, under the **same physical
729
753
  lock directory** above. Reading v1 preserves every unresolved identity and
730
754
  reservation; the next commit upgrades its format. v1 had no pending-ack queue,
@@ -1743,6 +1767,17 @@ tree or publish cached layout as current UI. Query a provider's observation
1743
1767
  command for its live lease state. These endpoints require rebuilt SDKs;
1744
1768
  installing a new CLI cannot patch an installed application's old SDK.
1745
1769
 
1770
+ iOS observation control uses the SDK's bounded capture lease, not physical-device
1771
+ action ownership. A rejected control or lost response cannot block later UI
1772
+ actions, and its HTTP request does not mark an enclosing UI action dispatched.
1773
+ An older Host may have left an `ios-command` / `ios-ui-observation` ownership
1774
+ marker. Explicit `ios-execution --operation reconcile` retires only that marker
1775
+ after checking the recorded device and any supplied bundle identity. Its receipt
1776
+ reports `reason: observation_control_not_device_mutation` and
1777
+ `observationOutcome: unknown`; it does not claim that observation started or
1778
+ stopped successfully. Other unresolved actions still require their original
1779
+ completion proof. Runtime restart alone does not erase the durable journal.
1780
+
1746
1781
  Ordinary CLI/MCP `feedback=full` opens a window before the action and releases
1747
1782
  it in finally. If observation is unavailable, the action is rejected before
1748
1783
  dispatch; acquiring evidence does not mark the enclosing UI action dispatched.
@@ -5,7 +5,8 @@ The same npm package contains CLI and MCP; running only MCP is supported.
5
5
  FactStore is an embedded library bundled in that package. There is no separate
6
6
  database, FactStore service or CLI process to install first.
7
7
 
8
- This source targets 0.4.0. Registry publication is a separate step; before
8
+ This source targets the coordinated 0.4.2 release.
9
+ Registry publication is a separate step; before
9
10
  publication, use the reviewed local tarball instead of expecting this registry
10
11
  version to resolve.
11
12
 
@@ -14,7 +15,7 @@ version to resolve.
14
15
  "mcpServers": {
15
16
  "ai-app-bridge": {
16
17
  "command": "npx",
17
- "args": ["--yes", "--package", "@mobileaidev/ai-app-bridge@0.4.0", "ai-app-bridge-mcp"]
18
+ "args": ["--yes", "--package", "@mobileaidev/ai-app-bridge@0.4.2", "ai-app-bridge-mcp"]
18
19
  }
19
20
  }
20
21
  }
@@ -1,4 +1,4 @@
1
- # Optional UI executors (0.4.0)
1
+ # Optional UI executors (0.4.2)
2
2
 
3
3
  Bridge keeps its existing SDK paths and exposes optional executors through `capabilities`, `run`, and JavaScript/Python Script. Select an executor explicitly. No command silently changes a touch into a setter, switches framework after failure, or repeats an uncertain action.
4
4
 
@@ -52,9 +52,9 @@ android {
52
52
  }
53
53
  }
54
54
  dependencies {
55
- androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-instrumentation:0.4.0")
55
+ androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-instrumentation:0.4.2")
56
56
  // Optional H5 adapter:
57
- androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-espresso-web:0.4.0")
57
+ androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-espresso-web:0.4.2")
58
58
  }
59
59
  ```
60
60
 
@@ -109,7 +109,7 @@ Espresso text actions have different semantics. `replaceText` is the framework's
109
109
 
110
110
  ## Flutter
111
111
 
112
- Add `ai_app_bridge_test: 0.4.0` to the application's `dev_dependencies`. The helper takes `flutter_test` and `integration_test` from the **same Flutter SDK** as the application. It is a Dart test helper, not an additional Android plugin with its own AGP/Kotlin versions.
112
+ Add `ai_app_bridge_test: 0.4.2` to the application's `dev_dependencies`. The helper takes `flutter_test` and `integration_test` from the **same Flutter SDK** as the application. It is a Dart test helper, not an additional Android plugin with its own AGP/Kotlin versions.
113
113
 
114
114
  The helper declares Flutter **>=3.41.0** and Dart **>=3.11.0 <4.0.0**. The 0.3.8 automatic preparation was exercised with LocalSend on Flutter **3.41.9 / Android API 36**, preserving all 214 production dependency versions. Earlier executor validation covered Flutter 3.41.9 / API 25 and 3.44.8 / API 36. These are specific verified combinations; other SDK versions still need validation with the application's plugin graph.
115
115
 
package/docs/RELEASE.md CHANGED
@@ -1,60 +1,37 @@
1
- # 0.4.0 改善版发行检查
2
-
3
- 源码版本、可发布验收、registry 发布和正在运行的客户端是四个不同状态。
4
- 本文件描述发行操作;源码中的版本号不代表 npm/JitPack/pub.dev 已发布。
5
- 当前实施与放行证据统一记录于仓库 `docs/IMPROVEMENT_RELEASE_V1_2026-09-18.md`
6
- 及各 M1–M5 交付记录。最终 A01–A16 对账未全通过前不宣称完整发布验收。
7
-
8
- ## 兼容与迁移
9
-
10
- 0.4.0 的外部 run 必填 `extract`,不提取显式 null;CLI `--extract null`。
11
- 返回公共封套含 execution/control/extraction/delivery,删除额外 `_history`、
12
- `_meta` 副本。旧请求不自动补字段。业务 value 在 null 且预算内保持原值,
13
- Script 的内部 ctx.call 仍返回 ok/result。Runtime 协议仍是 aab.runtime/v1。
14
-
15
- 调用方先核对 execution 和控制字段;提取失败后用原 source ref 调 response
16
- read,不重放动作。详见 [公共提取合同与完整示例](RESPONSE_EXTRACTION.md)。
17
- 发现正文超预算时按 command/operation 收窄,不静默裁剪 schema。
18
-
19
- ## 发行资源
20
-
21
- | 资源 | 源码版本 | 发行渠道 |
22
- | --- | --- | --- |
23
- | Desktop CLI/MCP | 0.4.0 | npm @mobileaidev/ai-app-bridge |
24
- | 嵌入式 native store | 0.2.0 | 随主包 bundleDependencies,包括四个预编译 addon |
25
- | Android SDK / Gradle plugin / executor modules | 0.4.0 | 同仓库 Git tag / JitPack |
26
- | iOS Swift 包 | Git tag 0.4.0 | 根 Package.swift |
27
- | Flutter SDK / test helper | 0.4.0 | pub.dev;Android 固定依赖同版 SDK |
28
- | Web SDK | 0.4.0 | npm @mobileaidev/ai-app-bridge-web |
29
-
30
- UIA bundle、WDA 14.1.1、iOS WDA 模板、Playwright helper 与三类范例随主包。
31
- 未改变代码的设备组件不需要仅为 Host 返回合同重新安装;需要测试新发行
32
- 设备产物时记录实际版本/包/序列号,不能用旧安装冒充新包验收。
33
-
34
- ## 发布前门禁
35
-
36
- 1. 固定审核提交,核对工作包和 A01–A16,保留失败与未验证项。完整 npm
37
- 功能组与安静环境串行性能组通过,范例由文档读取实际执行。
38
- 2. 对 [Host 支持矩阵](INSTALLATION.md) 的四个 artifact 校验 checksum 和
39
- 实际加载,运行 native tests 与全新 tarball 安装。正常安装和纯 MCP
40
- JS/regex 路径禁止调用本地编译器/Python;Python 回归使用单独环境。
41
- 3. 核对 npm pack 清单真实包含 addon、加载器、source read/worker 和文档,
42
- codeFingerprint 哈希实际选中的二进制。固定版本 npx 首次/重复启动、
43
- MCP 断连后 Runtime 存续、明确 stop/restart 与持久化恢复均有证据。
44
- 4. 核实实际 CLI 路径、MCP 启动版本/指纹、Runtime code/config/Node 身份,
45
- 并检查受影响消费脚本的 extract/公共响应迁移。安装成功不替代入口更新。
46
- 5. Android/Swift/Flutter/Web 的发行清单和版本一致。需要时运行相应构建,
47
- 真实设备证据和离线/受控 ADB 测试分开记录。三类实际任务对照保留来源、
48
- 时间、目标和原始记录,不能将缺设备写成不适用。
49
-
50
- ## 对外发布顺序
51
-
52
- 在已授权的发布操作中,维护者先推送已验收提交和 0.4.0 tag,核实 JitPack
53
- 公开坐标成功解析,再发布依赖它们的 Flutter SDK/helper。Web npm 与主
54
- CLI/MCP npm 分别发布,并核实 registry 实际返回的 tarball/checksum;设置
55
- 对应 dist-tag 和 GitHub Release。远端流水线成功与设备业务验收分别列明。
56
- 本地构建或 MavenLocal/path 替换不能证明公开坐标可安装。
57
-
58
- 客户端升级时退出旧 MCP 再重新连接。若是旧客户端碰到新 Runtime,先升
59
- 客户端;只有明确 Runtime 是待升级一侧时,在其任务结束后显式 stop。
60
- 同版本不同指纹只能说明构建或 Node 环境不一致,不能凭 hash 判断新旧。
1
+ # 0.4.2 统一补丁发行检查
2
+
3
+ 本次补齐 Android 卡死任务的强制停止与占用重置入口,并统一 CLI/MCP、
4
+ Android SDK/plugin/执行器、iOS Swift、Flutter SDK/helper、Web SDK 为 0.4.2。
5
+ 嵌入式 native store 保持 0.2.0。发布结果单独记录,源码版本不代表 registry 已发布。
6
+
7
+ ## 修复
8
+
9
+ - `device-ownership force-stop` 在 CLI/MCP 客户端独立执行,绕过挂住的
10
+ Runtime、执行队列和版本检查;终止旧 Host 及共享 Host 的子进程和任务。
11
+ - 手机 UIA 增加强制 reset:停止旧进程,在原 owner.lock 下归档未知/损坏记录,
12
+ 为新 epoch 清出可用空间。普通 cancel/reconcile 的原回执合同保持不变。
13
+ - 原任务结果保留为 `force_stopped/unknown`;占用错误附简短 recoveryHint。
14
+ 手机断开时本地占用仍释放,保留 reset-required 标记,重连后重复重置。
15
+ - SDK/Web 的执行行为不变,仅版本和依赖同步。新 UIA bundle 随 CLI 发布。
16
+
17
+ ## 放行检查
18
+
19
+ 1. 新增未知回执、活进程、并发占用、离线重试及提示合同回归;Host 完整功能组
20
+ 和串行性能组通过。UIA JVM 测试通过,DEX/manifest 同步到 CLI。
21
+ 2. OnePlus b46093e6 的旧 admitted 操作真实解除;新会话再次观察和操作成功,
22
+ 占用返回 idle。保存强制停止事实和新操作原始回执。
23
+ 3. Android build/lint/test、Swift iPhoneOS 构建、Flutter 分析和测试、Web 测试。
24
+ 版本同步不代替业务 App 验收;测试 helper 无 test 目录不记为测试通过。
25
+ 4. 最终 npm tarball 在仓库外安装;CLI/MCP 发现、提取、Script 和强制停止
26
+ 合同通过。未变更的四平台 native addon 校验归档字节。
27
+ 5. 两个 Flutter 包发布 dry-run;源码和公开依赖版本一致,历史 changelog 保留。
28
+
29
+ ## 发布与客户端升级
30
+
31
+ 推送验收提交和新 0.4.2 tag;核实 JitPack SDK/plugin/执行器坐标后发布
32
+ Flutter SDK/helper。发布受检 CLI 与 Web npm tarball,并将 latest/next
33
+ 统一为 0.4.2;核实公开下载 checksum,再创建 GitHub Release。
34
+ 旧 tag 保留。需要凭据或人工登录时明确记录阻塞,不把未发布渠道写成已发布。
35
+
36
+ 全局 CLI 升级后核实路径/版本/运行 Runtime。已有 MCP 连接需重连加载新的
37
+ 客户端控制入口。遇到旧占用可使用新 CLI 的 force-stop,不必等待旧任务。
@@ -70,6 +70,10 @@ requested delivery, 1 for validation/failed or unknown execution, and 2 when a
70
70
  known successful command could not be extracted/delivered. MCP uses `isError`
71
71
  consistently. A successful Script execution is separate from its business verdict.
72
72
 
73
+ A temporary-directory removal failure after the worker exits is reported as
74
+ `extraction.cleanupError`. It does not replace a successful value or the original
75
+ extraction error, change the exit code, or replay the command.
76
+
73
77
  Preserve source identity, timestamps, state and coverage needed by the actual
74
78
  assertion. A selected successful row does not prove complete business coverage.
75
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobileaidev/ai-app-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Desktop CLI and MCP server for AI App Bridge across Android, iOS, Flutter, WebView, and Web targets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "ai-app-bridge-playwright-runtime",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "ai-app-bridge-playwright-runtime",
9
- "version": "0.4.0",
9
+ "version": "0.4.1",
10
10
  "dependencies": {
11
11
  "playwright": "1.63.0"
12
12
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-app-bridge-playwright-runtime",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": true,
5
5
  "description": "Optional, isolated browser runtime managed by AI App Bridge.",
6
6
  "engines": { "node": ">=26.3.0 <27" },
Binary file
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": "aab.uia.bundle.v1",
3
3
  "mainClass": "io.github.mobileaidev.aiappbridge.uia.UiaRuntime",
4
4
  "artifact": "ai-app-bridge-uia.jar",
5
- "sha256": "54a78985b47adf05e2db95a1c4deb25a093bbf3f2b158c75d1fb96aaaddd2c80",
5
+ "sha256": "fa7c38c912a39a14b687ca53b16e849ea6f3c15cd8d0a1e29aa48fb1feb26637",
6
6
  "minApi": 25,
7
7
  "compileApi": 35,
8
8
  "buildTools": "36.0.0",
@@ -17,7 +17,8 @@
17
17
  "android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaHttp.java": "43447130a1829354e50c4df4dac2a66afea3ef7d9f8fa5732d773f526fc6e72a",
18
18
  "android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaJournal.java": "718a367429c68b16f56418e36d6f73f9147508b85e424c15a8d81c061a82a892",
19
19
  "android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaNodes.java": "afd426091f98ff9114ac9947c1c124a1167f566cc875e2063f171c9e42a298cf",
20
- "android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaRuntime.java": "bfeb921d91977350e1ced62bbac850fcd12ee527e3d837904b4536c5ab9516a4",
20
+ "android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaReset.java": "5dfd537ee83a4ae8e6bd87aab5606edf21c132271aea2d46609606e8baa488e5",
21
+ "android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaRuntime.java": "b8d1b298f29a3abcf80d0b5aba8a225607e08612f06b4deda56edcbb547bcac9",
21
22
  "android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/Wire.java": "d54e79c5e064abf5c7b36d2ab43205b7d47cca001ec37bf9b82ab01e33cf816c"
22
23
  }
23
24
  }
@@ -21,6 +21,8 @@ CLI 与 MCP 共用独立执行 Runtime、命令合同和 operationId。CLI 响
21
21
 
22
22
  旧 MCP 实例可能与已安装 CLI 不同。缺少 Intent/Script 或参数不匹配时,核对实际入口版本,选用支持当前合同的入口;不要套用旧 batch、工具别名或外层参数。
23
23
 
24
+ **卡死 / 换 agent:** Android 遇到 `target_busy`、`device_ownership_unresolved` 或旧动作一直没有终态时,按返回的 `recoveryHint` 调 `device-ownership {operation:"force-stop",serial:"原设备"}`;CLI 为 `ai-app-bridge device-ownership --operation force-stop --serial 原设备 --extract null`。这是独立重置入口:无需旧回执,强制结束旧 Host 及共享 Runtime 的任务,重置手机执行器并解除占用。共享这些 Host 的其他任务也会结束;已派发效果记为未知。读取 `ownershipReleased/remoteResetPending`;手机已重置后重新观察并开始新操作,手机断开时重连后再执行一次。
25
+
24
26
  ## 响应与提取
25
27
 
26
28
  先看 `execution` 的原执行事实及 `failureStage`,再消费 `value`;Script 内部 `ctx.call` 仍按 `ok/result` 处理,不加 extract。`control` 保留续跑字段和采集覆盖;当前 Script 问题从 `control.pendingQuestion` 读取,即使 events 被游标过滤也可回答。