@deeeed/metamask-harness 0.43.0 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.44.1 - 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - Accept retained market context as lifecycle-valid Homepage evidence during account-only switches.
10
+ - Require controller-backed Mobile unlock proof before continuing a recipe after startup or restart.
11
+
12
+ ## 0.44.0 - 2026-08-25
13
+
14
+ ### Fixed
15
+
16
+ - Remove `cold_disk_cache` from the `perps.performance` lifecycle enum because disk hydration is a cache source within a lifecycle, not a standalone lifecycle.
17
+ - Await promise-returning Mobile `press-test-id` and `set-input` bridge handlers so recipe nodes finish only after the in-app operation settles.
18
+ - Keep Mobile broker commands available while Hermes installs the in-app bridge after a target handoff, instead of reconnecting the inspector until actions time out.
19
+ - Honor `launch --build` when a healthy Mobile runtime is already connected; the explicit native build can no longer return a false pass without invoking the build leaf.
20
+ - Let resident Homepage and short-resume performance recipes finish from the ordered visible-content sequence without requiring a new live takeover.
21
+ - Make Mobile reload stay on the pinned device, fail before an active Android Hermes sampler can abort the app, and fail if the process exits or a replacement target never appears; reload never relaunches the app.
22
+ - Bind run evidence to the executable Harness source, build, bin, package, library, and adapter inputs plus task-local command/config helpers, and reject input drift or symlink swaps.
23
+ - Persist recovery and mutation lists in recipe packages, include a redacted run-scoped application log, reject failed recovery before retrying, and prove lifecycle continuity with native process IDs, including opaque runtimes when requested.
24
+ - Refuse stale Mobile source under every heal policy unless an authored `app.lifecycle restart` reloads it, and invalidate run evidence if recipe, library, or product provenance drifts after preparation.
25
+ - Require Homepage demand, committed UI, resolved content, and live-current frame records before the canonical Mobile Perps performance recipe can pass.
26
+ - Fail a Mobile recipe or action without implicitly restarting Metro or the app; process restarts remain explicit lifecycle actions.
27
+ - Keep one Mobile inspector connection open while React Native replays its console buffer, avoiding repeated `Runtime.enable` requests that can stall Metro after unlock.
28
+ - Allow `metamask.wallet.lock` to use the same visible Android controls on development and opaque clients.
29
+ - Keep account-selection proof attached across the temporary Hermes/CDP stall caused by the account switch itself.
30
+ - Add `metamask.perps.ensure_mode` so recipes can reach Lite or Pro through the visible mode control without inheriting prior device state.
31
+ - Preserve Homepage boundary source and content-variant metadata in performance summaries, and bind recipe manifests to the exact product checkout commit.
32
+ - Accept only structured, generation-coherent Perps live-stream records as performance proof, and keep Mobile wallet and mode probes inside one absolute action deadline.
33
+ - Keep the latest automatic Mobile HUD update in the CDP broker and retry it until the in-app bridge applies it when the pinned Hermes target returns, without restarting the app.
34
+ - Keep the Perps performance recipe's empty-account setup read-only and allow its setup-only prime capture to contain no performance records.
35
+ - Redact quoted JSON credential fields before persisting run-scoped application logs.
36
+
5
37
  ## 0.43.0 - 2026-08-21
6
38
 
7
39
  ### Added
@@ -18,6 +18,7 @@
18
18
 
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
21
22
  const { loadPort } = require('./lib/config.cjs');
22
23
  const {
23
24
  discoverTarget,
@@ -36,6 +37,126 @@ const {
36
37
  } = require('./lib/bridge-errors.cjs');
37
38
  const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
38
39
  const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
40
+ const APPLY_HUD_UPDATE_FUNCTION = `function(step) {
41
+ const bridge = this.__AGENTIC__;
42
+ if (step === null) {
43
+ if (typeof bridge?.hideStep !== 'function') return false;
44
+ setTimeout(() => bridge.hideStep(), 0);
45
+ return true;
46
+ }
47
+ if (typeof bridge?.showStep !== 'function') return false;
48
+ setTimeout(() => bridge.showStep(step), 0);
49
+ return true;
50
+ }`;
51
+
52
+ function mobileToolPath(tool) {
53
+ return process.env[`MM_HARNESS_${tool.toUpperCase()}_PATH`] || tool;
54
+ }
55
+
56
+ function bootedIosDevice(deviceName) {
57
+ if (!deviceName) return null;
58
+ try {
59
+ const devices = JSON.parse(
60
+ execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '-j'], {
61
+ encoding: 'utf8',
62
+ }),
63
+ );
64
+ const device = Object.values(devices.devices || {})
65
+ .flat()
66
+ .find(
67
+ (candidate) =>
68
+ (candidate?.name === deviceName || candidate?.udid === deviceName) &&
69
+ candidate?.state === 'Booted',
70
+ );
71
+ return device?.udid ? device : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function tapVisibleIosAccessibilityTarget(testId, deviceName, platform) {
78
+ if (platform !== 'ios') return null;
79
+ const device = bootedIosDevice(deviceName);
80
+ if (!device) return null;
81
+ try {
82
+ const idb = mobileToolPath('idb');
83
+ const elements = JSON.parse(
84
+ execFileSync(
85
+ idb,
86
+ ['ui', 'describe-all', '--udid', device.udid, '--json'],
87
+ { encoding: 'utf8' },
88
+ ),
89
+ );
90
+ const windowFrame = elements
91
+ .map((element) => element?.frame)
92
+ .filter(
93
+ (frame) =>
94
+ frame &&
95
+ frame.x === 0 &&
96
+ frame.y === 0 &&
97
+ frame.width > 0 &&
98
+ frame.height > 0,
99
+ )
100
+ .sort(
101
+ (first, second) =>
102
+ second.width * second.height - first.width * first.height,
103
+ )[0];
104
+ if (!windowFrame) return null;
105
+ const target = elements.find((element) => {
106
+ const frame = element?.frame;
107
+ return (
108
+ element?.AXUniqueId === testId &&
109
+ element?.enabled !== false &&
110
+ element?.hittable !== false &&
111
+ frame &&
112
+ frame.width > 0 &&
113
+ frame.height > 0 &&
114
+ frame.x >= 0 &&
115
+ frame.y >= 0 &&
116
+ frame.x + frame.width <= windowFrame.width &&
117
+ frame.y + frame.height <= windowFrame.height
118
+ );
119
+ });
120
+ if (!target) return null;
121
+ const x = Math.round(target.frame.x + target.frame.width / 2);
122
+ const y = Math.round(target.frame.y + target.frame.height / 2);
123
+ execFileSync(
124
+ idb,
125
+ ['ui', 'tap', String(x), String(y), '--udid', device.udid],
126
+ { encoding: 'utf8' },
127
+ );
128
+ return { ok: true, testId, deviceName, provider: 'idb-accessibility' };
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ async function applyHudUpdate(client, step) {
135
+ const globalObject = await client.send('Runtime.evaluate', {
136
+ expression: 'globalThis',
137
+ returnByValue: false,
138
+ awaitPromise: false,
139
+ });
140
+ const objectId = globalObject?.result?.objectId;
141
+ if (!objectId) {
142
+ throw new Error('Mobile HUD runtime did not expose globalThis');
143
+ }
144
+ const result = await client.send('Runtime.callFunctionOn', {
145
+ objectId,
146
+ functionDeclaration: APPLY_HUD_UPDATE_FUNCTION,
147
+ arguments: [{ value: step }],
148
+ returnByValue: true,
149
+ awaitPromise: false,
150
+ });
151
+ if (result?.exceptionDetails) {
152
+ throw new Error(
153
+ result.exceptionDetails.exception?.description ||
154
+ result.exceptionDetails.text ||
155
+ 'Mobile HUD update failed',
156
+ );
157
+ }
158
+ return result?.result?.value === true;
159
+ }
39
160
 
40
161
  function parsePerformanceConsoleEvent(params) {
41
162
  const markers = ['[PerpsPerf] ', '[HomepagePerf] '];
@@ -97,7 +218,7 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
97
218
  throw new Error('Usage: set-input <testId> <value>');
98
219
  }
99
220
  const expr = `(function() {
100
- if (globalThis.__AGENTIC__?.setInput) return globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)});
221
+ if (globalThis.__AGENTIC__?.setInput) return Promise.resolve(globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)}));
101
222
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
102
223
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
103
224
  var renderers = hook.renderers;
@@ -133,7 +254,7 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
133
254
  })()`;
134
255
  let result;
135
256
  try {
136
- result = await cdpEval(client, expr);
257
+ result = await cdpEvalAsync(client, expr);
137
258
  } catch (error) {
138
259
  if (redact) {
139
260
  throw new Error(`Secret input could not be applied for testID ${testId}.`);
@@ -151,6 +272,26 @@ async function setInput(client, testId, value, { deviceName } = {}, redact = fal
151
272
  };
152
273
  }
153
274
 
275
+ function parseHudStep(args) {
276
+ const raw = args.join(' ');
277
+ let step;
278
+ try {
279
+ step = JSON.parse(raw);
280
+ } catch (error) {
281
+ throw new Error(`show-step-json requires a JSON step payload: ${error.message}`);
282
+ }
283
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
284
+ throw new Error('show-step-json requires a JSON object step payload');
285
+ }
286
+ if (typeof step.intent !== 'string' || !step.intent.trim()) {
287
+ throw new Error('show-step-json requires step.intent');
288
+ }
289
+ if (step.id !== undefined && typeof step.id !== 'string') {
290
+ throw new Error('show-step-json step.id must be a string when provided');
291
+ }
292
+ return step;
293
+ }
294
+
154
295
  const COMMANDS = {
155
296
  async navigate(client, args, { deviceName, platform } = {}) {
156
297
  const routeName = ROUTE_ALIASES[args[0]] || args[0];
@@ -277,8 +418,15 @@ const COMMANDS = {
277
418
  var agenticPresent = typeof globalThis.__AGENTIC__ !== 'undefined';
278
419
  var route = globalThis.__AGENTIC__?.getRoute() || null;
279
420
  var account = null;
421
+ var keyringUnlocked = false;
280
422
  try { account = globalThis.__AGENTIC__?.getSelectedAccount() || null; } catch(e) {}
281
- return { route: route, account: account, agenticPresent: agenticPresent };
423
+ try { keyringUnlocked = globalThis.Engine?.context?.KeyringController?.isUnlocked?.() === true; } catch(e) {}
424
+ return {
425
+ route: route,
426
+ account: account,
427
+ agenticPresent: agenticPresent,
428
+ keyringUnlocked: keyringUnlocked,
429
+ };
282
430
  })()`;
283
431
  const snapshot = await cdpEval(client, expr);
284
432
  return { ...snapshot, deviceName: deviceName || '', platform: platform || '' };
@@ -300,14 +448,15 @@ const COMMANDS = {
300
448
  return await cdpEval(client, `globalThis.__AGENTIC__?.switchAccount(${JSON.stringify(address)})`);
301
449
  },
302
450
 
303
- async 'press-test-id'(client, args, { deviceName } = {}) {
451
+ async 'press-test-id'(client, args, { deviceName, platform } = {}) {
304
452
  const testId = args[0];
305
453
  if (!testId) {
306
454
  throw new Error('Usage: press-test-id <testId>');
307
455
  }
308
- // Try __AGENTIC__ bridge first, fall back to inline fiber walking
456
+ // Try the app bridge first. Native accessibility remains an iOS fallback
457
+ // for controls whose React handler cannot be invoked directly.
309
458
  const expr = `(function() {
310
- if (globalThis.__AGENTIC__?.pressTestId) return globalThis.__AGENTIC__.pressTestId(${JSON.stringify(testId)});
459
+ if (globalThis.__AGENTIC__?.pressTestId) return Promise.resolve(globalThis.__AGENTIC__.pressTestId(${JSON.stringify(testId)}));
311
460
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
312
461
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
313
462
  var renderers = hook.renderers;
@@ -330,10 +479,72 @@ const COMMANDS = {
330
479
  }
331
480
  return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onPress' };
332
481
  })()`;
333
- const result = await cdpEval(client, expr);
482
+ let result;
483
+ let bridgeError;
484
+ try {
485
+ result = await cdpEvalAsync(client, expr);
486
+ if (result?.ok !== false) {
487
+ return { ...result, testId, deviceName };
488
+ }
489
+ } catch (error) {
490
+ bridgeError = error;
491
+ }
492
+ const nativeResult = tapVisibleIosAccessibilityTarget(
493
+ testId,
494
+ deviceName,
495
+ platform,
496
+ );
497
+ if (nativeResult) return nativeResult;
498
+ if (bridgeError) throw bridgeError;
334
499
  return { ...result, testId, deviceName };
335
500
  },
336
501
 
502
+ async 'key-press'(_client, args, { deviceName, platform } = {}) {
503
+ const requestedKey = String(args[0] || 'Enter').toLowerCase();
504
+ const isEnter = requestedKey === 'enter' || requestedKey === 'return';
505
+ const isBack = requestedKey === 'escape' || requestedKey === 'back';
506
+ if (!isEnter && !isBack) {
507
+ throw new Error(`Unsupported Mobile key ${JSON.stringify(args[0])}`);
508
+ }
509
+ if (platform === 'android') {
510
+ const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
511
+ if (!serial) throw new Error('key-press requires a selected Android device');
512
+ const keyCode = isEnter ? '66' : '4';
513
+ execFileSync(
514
+ mobileToolPath('adb'),
515
+ ['-s', serial, 'shell', 'input', 'keyevent', keyCode],
516
+ { encoding: 'utf8' },
517
+ );
518
+ return {
519
+ ok: true,
520
+ key: args[0] || 'Enter',
521
+ keyCode,
522
+ deviceName,
523
+ provider: 'adb-key',
524
+ };
525
+ }
526
+ if (platform !== 'ios') {
527
+ throw new Error(`key-press requires a selected Mobile platform`);
528
+ }
529
+ const device = bootedIosDevice(deviceName);
530
+ if (!device) {
531
+ throw new Error(`No booted simulator named ${JSON.stringify(deviceName)}`);
532
+ }
533
+ const keyCode = isEnter ? '40' : '41';
534
+ execFileSync(
535
+ mobileToolPath('idb'),
536
+ ['ui', 'key', keyCode, '--udid', device.udid],
537
+ { encoding: 'utf8' },
538
+ );
539
+ return {
540
+ ok: true,
541
+ key: args[0] || 'Enter',
542
+ keyCode,
543
+ deviceName,
544
+ provider: 'idb-key',
545
+ };
546
+ },
547
+
337
548
  async 'long-press-test-id'(client, args, { deviceName } = {}) {
338
549
  const testId = args[0];
339
550
  if (!testId) {
@@ -406,6 +617,9 @@ const COMMANDS = {
406
617
  // selector fiber. For into-view requests the scroll container is commonly
407
618
  // an ancestor (for example, a Wallet Home section row), so use the
408
619
  // ancestor-aware bridge fallback below instead.
620
+ if (${intoView} && globalThis.__AGENTIC__?.scrollIntoView) {
621
+ return globalThis.__AGENTIC__.scrollIntoView(${JSON.stringify(testId)}, ${animated});
622
+ }
409
623
  if (!${intoView} && globalThis.__AGENTIC__?.scrollView) return globalThis.__AGENTIC__.scrollView(${optsJson});
410
624
  var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
411
625
  if (!hook) return { ok: false, error: 'No React DevTools hook' };
@@ -427,14 +641,56 @@ const COMMANDS = {
427
641
  }
428
642
  return false;
429
643
  }
644
+ function findMeasurable(fiber) {
645
+ if (!fiber) return null;
646
+ var sn = fiber.stateNode;
647
+ if (sn && typeof sn.measureLayout === 'function') return sn;
648
+ return findMeasurable(fiber.child);
649
+ }
430
650
  function tryScrollNear(anchor) {
431
651
  if (!opts.intoView && tryScroll(anchor, false)) return true;
652
+ var target = opts.intoView ? findMeasurable(anchor) : null;
432
653
  var current = anchor ? anchor.return : null;
433
654
  while (current) {
434
655
  var sn = current.stateNode;
656
+ var currentProps = current.memoizedProps;
435
657
  if (sn) {
436
- if (typeof sn.scrollTo === 'function') { sn.scrollTo({ y: opts.offset, animated: opts.animated }); return true; }
437
- if (typeof sn.scrollToOffset === 'function') { sn.scrollToOffset({ offset: opts.offset, animated: opts.animated }); return true; }
658
+ if (typeof sn.scrollTo === 'function' && !(currentProps && currentProps.horizontal === true)) {
659
+ if (target) {
660
+ return new Promise(function(resolve) {
661
+ target.measureLayout(
662
+ sn,
663
+ function(x, y) {
664
+ sn.scrollTo({ y: y, animated: opts.animated });
665
+ resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
666
+ },
667
+ function() {
668
+ resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
669
+ }
670
+ );
671
+ });
672
+ }
673
+ sn.scrollTo({ y: opts.offset, animated: opts.animated });
674
+ return true;
675
+ }
676
+ if (typeof sn.scrollToOffset === 'function' && !(currentProps && currentProps.horizontal === true)) {
677
+ if (target) {
678
+ return new Promise(function(resolve) {
679
+ target.measureLayout(
680
+ sn,
681
+ function(x, y) {
682
+ sn.scrollToOffset({ offset: y, animated: opts.animated });
683
+ resolve({ ok: true, testId: opts.testId, measuredOffset: y, animated: opts.animated });
684
+ },
685
+ function() {
686
+ resolve({ ok: false, error: 'Unable to measure testID=' + opts.testId + ' relative to its scroll container' });
687
+ }
688
+ );
689
+ });
690
+ }
691
+ sn.scrollToOffset({ offset: opts.offset, animated: opts.animated });
692
+ return true;
693
+ }
438
694
  }
439
695
  current = current.return;
440
696
  }
@@ -443,7 +699,23 @@ const COMMANDS = {
443
699
  function findTestId(fiber) {
444
700
  if (!fiber) return null;
445
701
  var props = fiber.memoizedProps;
446
- if (props && props.testID === opts.testId) return fiber;
702
+ if (props && props.testID === opts.testId) {
703
+ var ancestor = fiber;
704
+ var inactive = false;
705
+ while (ancestor) {
706
+ var ancestorProps = ancestor.memoizedProps;
707
+ var ancestorStyle = ancestorProps && ancestorProps.style;
708
+ if (
709
+ (ancestorProps && ancestorProps.activityState === 0) ||
710
+ (ancestorStyle && ancestorStyle.display === 'none')
711
+ ) {
712
+ inactive = true;
713
+ break;
714
+ }
715
+ ancestor = ancestor.return;
716
+ }
717
+ if (!inactive) return fiber;
718
+ }
447
719
  return findTestId(fiber.child) || findTestId(fiber.sibling);
448
720
  }
449
721
  for (var [id] of renderers) {
@@ -459,11 +731,16 @@ const COMMANDS = {
459
731
  scrolled = tryScroll(r.current);
460
732
  }
461
733
  });
462
- if (scrolled) return { ok: true, testId: opts.testId, offset: opts.offset, animated: opts.animated };
734
+ if (scrolled) {
735
+ if (typeof scrolled.then === 'function') return scrolled;
736
+ return { ok: true, testId: opts.testId, offset: opts.offset, animated: opts.animated };
737
+ }
463
738
  }
464
739
  return { ok: false, error: opts.testId ? 'No scrollable near testID=' + opts.testId : 'No scrollable found' };
465
740
  })()`;
466
- const result = await cdpEval(client, expr);
741
+ const result = intoView
742
+ ? await cdpEvalAsync(client, expr)
743
+ : await cdpEval(client, expr);
467
744
  return { ...result, deviceName };
468
745
  },
469
746
 
@@ -841,34 +1118,33 @@ const COMMANDS = {
841
1118
  },
842
1119
 
843
1120
  async 'show-step-json'(client, args) {
844
- const raw = args.join(' ');
845
- let step;
846
- try {
847
- step = JSON.parse(raw);
848
- } catch (error) {
849
- throw new Error(`show-step-json requires a JSON step payload: ${error.message}`);
850
- }
851
- if (!step || typeof step !== 'object' || Array.isArray(step)) {
852
- throw new Error('show-step-json requires a JSON object step payload');
853
- }
854
- if (typeof step.intent !== 'string' || !step.intent.trim()) {
855
- throw new Error('show-step-json requires step.intent');
856
- }
857
- // HUD derives status/progress from step.id when status is omitted; a
858
- // non-string id makes statusForStep throw during render. Reject at the boundary.
859
- if (step.id !== undefined && typeof step.id !== 'string') {
860
- throw new Error('show-step-json step.id must be a string when provided');
861
- }
862
- const payload = JSON.stringify(step);
863
- await cdpEval(client, `globalThis.__AGENTIC__?.showStep && globalThis.__AGENTIC__.showStep(${payload})`);
1121
+ const step = parseHudStep(args);
1122
+ await applyHudUpdate(client, step);
864
1123
  return { ok: true };
865
1124
  },
866
1125
 
1126
+ async 'show-step-json-deferred'(client, args) {
1127
+ const step = parseHudStep(args);
1128
+ if (typeof client.control === 'function') {
1129
+ return client.control('hud-update', { step });
1130
+ }
1131
+ await applyHudUpdate(client, step);
1132
+ return { ok: true, status: 'applied' };
1133
+ },
1134
+
867
1135
  async 'hide-step'(client) {
868
- await cdpEval(client, `globalThis.__AGENTIC__?.hideStep && globalThis.__AGENTIC__.hideStep()`);
1136
+ await applyHudUpdate(client, null);
869
1137
  return { ok: true };
870
1138
  },
871
1139
 
1140
+ async 'hide-step-deferred'(client) {
1141
+ if (typeof client.control === 'function') {
1142
+ return client.control('hud-update', { step: null });
1143
+ }
1144
+ await applyHudUpdate(client, null);
1145
+ return { ok: true, status: 'applied' };
1146
+ },
1147
+
872
1148
  async 'profiler-start'(client) {
873
1149
  // Hermes CDP exposes the sampling profiler via the Profiler domain.
874
1150
  // Output of Profiler.stop is a Chrome-compatible .cpuprofile object.
@@ -1170,8 +1446,9 @@ Environment:
1170
1446
  const client = await clientFor(wsUrl);
1171
1447
 
1172
1448
  try {
1173
- // Detect platform from the running app (exposed by __AGENTIC__ bridge as Platform.OS)
1174
- const platform = await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
1449
+ const platform = command.endsWith('-deferred')
1450
+ ? ''
1451
+ : await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
1175
1452
  const result = await handler(client, args.slice(1), { deviceName, platform });
1176
1453
  console.log(JSON.stringify(result, null, 2));
1177
1454
  } finally {
@@ -40,6 +40,7 @@ const { resolvePort } = require('./lib/config.cjs');
40
40
  // Hermes uses Node's built-in WebSocket client. The local DevTools proxy uses
41
41
  // the package's pinned `ws` server implementation.
42
42
  const HANDSHAKE_TIMEOUT_MS = 3000;
43
+ const RUNTIME_ENABLE_TIMEOUT_MS = 60_000;
43
44
 
44
45
  const DISCOVER_ACTIVE_MS = 1000; // a device is unattached — look for it quickly
45
46
  const DISCOVER_STEADY_MS = 10000; // all known targets attached — cheap liveness tick
@@ -311,25 +312,36 @@ function connect(target) {
311
312
  ws.addEventListener('open', async () => {
312
313
  clearTimeout(handshakeTimer);
313
314
  session.opened = true;
315
+ // A live inspector socket is enough for broker commands. Do not keep the
316
+ // broker unavailable while Runtime.enable replays a large console buffer or
317
+ // while __AGENTIC__ is being installed after a Hermes context handoff.
318
+ // Commands such as status already report agenticPresent=false and their
319
+ // callers retry against the same bounded action deadline.
320
+ session.brokerReady = true;
321
+ broker.onSessionOpen(deviceId);
322
+ devtoolsProxy.onSessionOpen(deviceId, session);
323
+ process.stderr.write(`console-forwarder: attached ${name}\n`);
314
324
  try {
315
- await sendCommand(session, 'Runtime.enable');
325
+ await sendCommand(
326
+ session,
327
+ 'Runtime.enable',
328
+ {},
329
+ RUNTIME_ENABLE_TIMEOUT_MS,
330
+ );
316
331
  const evaluation = await sendCommand(session, 'Runtime.evaluate', {
317
332
  expression: "typeof globalThis.__AGENTIC__ === 'object'",
318
333
  returnByValue: true,
319
334
  awaitPromise: false,
320
335
  });
321
336
  if (evaluation?.result?.value !== true) {
322
- throw new Error('CDP target does not expose __AGENTIC__');
337
+ process.stderr.write(
338
+ `console-forwarder: ${name} is waiting for __AGENTIC__\n`,
339
+ );
323
340
  }
324
- session.brokerReady = true;
325
- broker.onSessionOpen(deviceId);
326
- devtoolsProxy.onSessionOpen(deviceId, session);
327
- process.stderr.write(`console-forwarder: attached ${name}\n`);
328
341
  } catch (error) {
329
342
  process.stderr.write(
330
- `console-forwarder: rejected ${name}: ${String(error?.message || error).slice(0, 256)}\n`,
343
+ `console-forwarder: console bootstrap pending for ${name}: ${String(error?.message || error).slice(0, 256)}\n`,
331
344
  );
332
- ws.close();
333
345
  }
334
346
  });
335
347
  ws.addEventListener('message', (event) => {