@tangle-network/browser-agent-driver 0.24.1 → 0.25.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 (84) hide show
  1. package/dist/brain/index.d.ts +5 -0
  2. package/dist/brain/index.d.ts.map +1 -1
  3. package/dist/brain/index.js +80 -32
  4. package/dist/brain/index.js.map +1 -1
  5. package/dist/cli-attach.d.ts +128 -0
  6. package/dist/cli-attach.d.ts.map +1 -0
  7. package/dist/cli-attach.js +254 -0
  8. package/dist/cli-attach.js.map +1 -0
  9. package/dist/cli-preview.d.ts +59 -0
  10. package/dist/cli-preview.d.ts.map +1 -0
  11. package/dist/cli-preview.js +144 -0
  12. package/dist/cli-preview.js.map +1 -0
  13. package/dist/cli-share.d.ts +77 -0
  14. package/dist/cli-share.d.ts.map +1 -0
  15. package/dist/cli-share.js +141 -0
  16. package/dist/cli-share.js.map +1 -0
  17. package/dist/cli-ui.d.ts.map +1 -1
  18. package/dist/cli-ui.js +15 -1
  19. package/dist/cli-ui.js.map +1 -1
  20. package/dist/cli.js +262 -14
  21. package/dist/cli.js.map +1 -1
  22. package/dist/drivers/cursor-overlay.d.ts +18 -10
  23. package/dist/drivers/cursor-overlay.d.ts.map +1 -1
  24. package/dist/drivers/cursor-overlay.js +221 -30
  25. package/dist/drivers/cursor-overlay.js.map +1 -1
  26. package/dist/drivers/overlay-label.d.ts +33 -0
  27. package/dist/drivers/overlay-label.d.ts.map +1 -0
  28. package/dist/drivers/overlay-label.js +92 -0
  29. package/dist/drivers/overlay-label.js.map +1 -0
  30. package/dist/drivers/playwright.d.ts +30 -0
  31. package/dist/drivers/playwright.d.ts.map +1 -1
  32. package/dist/drivers/playwright.js +204 -16
  33. package/dist/drivers/playwright.js.map +1 -1
  34. package/dist/drivers/snapshot.d.ts +12 -0
  35. package/dist/drivers/snapshot.d.ts.map +1 -1
  36. package/dist/drivers/snapshot.js +17 -0
  37. package/dist/drivers/snapshot.js.map +1 -1
  38. package/dist/drivers/types.d.ts +13 -0
  39. package/dist/drivers/types.d.ts.map +1 -1
  40. package/dist/multi-actor.d.ts.map +1 -1
  41. package/dist/multi-actor.js +4 -0
  42. package/dist/multi-actor.js.map +1 -1
  43. package/dist/run-state.d.ts.map +1 -1
  44. package/dist/run-state.js +12 -2
  45. package/dist/run-state.js.map +1 -1
  46. package/dist/runner/interrupt-controller.d.ts +67 -0
  47. package/dist/runner/interrupt-controller.d.ts.map +1 -0
  48. package/dist/runner/interrupt-controller.js +142 -0
  49. package/dist/runner/interrupt-controller.js.map +1 -0
  50. package/dist/runner/overlay-narration.d.ts +83 -0
  51. package/dist/runner/overlay-narration.d.ts.map +1 -0
  52. package/dist/runner/overlay-narration.js +172 -0
  53. package/dist/runner/overlay-narration.js.map +1 -0
  54. package/dist/runner/parallel-runner.d.ts +4 -0
  55. package/dist/runner/parallel-runner.d.ts.map +1 -1
  56. package/dist/runner/parallel-runner.js +1 -0
  57. package/dist/runner/parallel-runner.js.map +1 -1
  58. package/dist/runner/runner.d.ts +17 -0
  59. package/dist/runner/runner.d.ts.map +1 -1
  60. package/dist/runner/runner.js +72 -50
  61. package/dist/runner/runner.js.map +1 -1
  62. package/dist/runner/stream-webhook.d.ts +70 -0
  63. package/dist/runner/stream-webhook.d.ts.map +1 -0
  64. package/dist/runner/stream-webhook.js +132 -0
  65. package/dist/runner/stream-webhook.js.map +1 -0
  66. package/dist/skills/domain-loader.d.ts +78 -0
  67. package/dist/skills/domain-loader.d.ts.map +1 -0
  68. package/dist/skills/domain-loader.js +201 -0
  69. package/dist/skills/domain-loader.js.map +1 -0
  70. package/dist/skills/macro-loader.d.ts +95 -0
  71. package/dist/skills/macro-loader.d.ts.map +1 -0
  72. package/dist/skills/macro-loader.js +237 -0
  73. package/dist/skills/macro-loader.js.map +1 -0
  74. package/dist/supervisor/critic.js +1 -1
  75. package/dist/supervisor/policy.js +7 -0
  76. package/dist/supervisor/policy.js.map +1 -1
  77. package/dist/test-runner.d.ts +13 -0
  78. package/dist/test-runner.d.ts.map +1 -1
  79. package/dist/test-runner.js +6 -0
  80. package/dist/test-runner.js.map +1 -1
  81. package/dist/types.d.ts +19 -1
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/viewer/viewer.html +122 -8
  84. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -75,6 +75,7 @@ async function main() {
75
75
  goal: { type: 'string', short: 'g' },
76
76
  url: { type: 'string', short: 'u' },
77
77
  cases: { type: 'string', short: 'c' },
78
+ 'cases-json': { type: 'string' },
78
79
  'allowed-domains': { type: 'string' },
79
80
  // LLM configuration
80
81
  model: { type: 'string', short: 'm' },
@@ -146,6 +147,8 @@ async function main() {
146
147
  'user-data-dir': { type: 'string' },
147
148
  'profile-dir': { type: 'string' },
148
149
  'cdp-url': { type: 'string' },
150
+ attach: { type: 'boolean' },
151
+ 'attach-port': { type: 'string' },
149
152
  wallet: { type: 'boolean' },
150
153
  'wallet-auto-approve': { type: 'boolean' },
151
154
  'wallet-password': { type: 'string' },
@@ -177,6 +180,16 @@ async function main() {
177
180
  cookie: { type: 'string', multiple: true },
178
181
  'wait-for': { type: 'string' },
179
182
  'wait-timeout': { type: 'string' },
183
+ // Gen 32 — `bad share` flags
184
+ visibility: { type: 'string' },
185
+ 'bad-app-url': { type: 'string' },
186
+ 'no-copy': { type: 'boolean' },
187
+ // Gen 32 — preview / stream / interrupt
188
+ 'max-steps': { type: 'string' },
189
+ headed: { type: 'boolean' },
190
+ stream: { type: 'string' },
191
+ 'stream-token': { type: 'string' },
192
+ interrupt: { type: 'boolean' },
180
193
  help: { type: 'boolean', short: 'h', default: false },
181
194
  version: { type: 'boolean', short: 'v', default: false },
182
195
  },
@@ -317,6 +330,16 @@ async function main() {
317
330
  });
318
331
  process.exit(0);
319
332
  }
333
+ if (command === 'chrome-debug') {
334
+ const { runChromeDebugCommand } = await import('./cli-attach.js');
335
+ const port = values['attach-port'] ? parseInt(values['attach-port'], 10) : undefined;
336
+ const rc = await runChromeDebugCommand({
337
+ port,
338
+ userDataDir: values['user-data-dir'],
339
+ quiet: values.quiet,
340
+ });
341
+ process.exit(rc);
342
+ }
320
343
  if (command === 'auth') {
321
344
  const sub = positionals[1];
322
345
  if (sub === 'save') {
@@ -351,13 +374,85 @@ async function main() {
351
374
  cliError(`Unknown auth subcommand: ${sub || '(none)'}. Use "auth save", "auth login", or "auth check".`);
352
375
  process.exit(1);
353
376
  }
354
- if (command !== 'run') {
355
- cliError(`Unknown command: ${command}. Use "run", "runs", "design-audit", "view", "showcase", or "auth".`);
377
+ // `bad attach` is a top-level alias for `bad run --attach`. Every other
378
+ // flag on `run` (--goal, --url, --model, --provider, --base-url,
379
+ // --api-key, --show-cursor, --mode, --max-turns, --timeout, --no-memory,
380
+ // --attach-port) works identically. Attach's mental model is "drive my
381
+ // real Chrome," which is distinct enough from "spawn a fresh browser"
382
+ // to deserve its own command name.
383
+ if (command === 'attach') {
384
+ values.attach = true;
385
+ }
386
+ // `bad preview` — plan-only dry-run. Observe the URL once, ask the
387
+ // planner to emit a structured plan, render it, exit. No execution.
388
+ // terraform plan for browser agents.
389
+ if (command === 'preview') {
390
+ if (!values.goal || !values.url) {
391
+ cliError('usage: bad preview --goal "..." --url <url> [--max-steps 12] [--headed] [--json] [--out plan.json]');
392
+ process.exit(1);
393
+ }
394
+ const { handlePreviewCommand, PreviewError } = await import('./cli-preview.js');
395
+ try {
396
+ const result = await handlePreviewCommand({
397
+ goal: values.goal,
398
+ url: values.url,
399
+ model: values.model,
400
+ provider: values.provider,
401
+ apiKey: values['api-key'],
402
+ baseUrl: values['base-url'],
403
+ output: values.sink,
404
+ json: values.json,
405
+ maxSteps: values['max-steps'] ? parseInt(values['max-steps'], 10) : undefined,
406
+ headed: values.headed,
407
+ });
408
+ process.exit(result.plan ? 0 : 1);
409
+ }
410
+ catch (err) {
411
+ if (err instanceof PreviewError) {
412
+ cliError(err.message);
413
+ process.exit(1);
414
+ }
415
+ throw err;
416
+ }
417
+ }
418
+ // `bad share <run-id>` — create a bad-app share link, copy to clipboard.
419
+ if (command === 'share') {
420
+ const runId = positionals[1];
421
+ if (!runId) {
422
+ cliError('usage: bad share <run-id> [--visibility metadata|full|artifacts] [--json]');
423
+ process.exit(1);
424
+ }
425
+ const { handleShareCommand, ShareError } = await import('./cli-share.js');
426
+ const visArg = values.visibility;
427
+ const visibility = visArg === 'full' || visArg === 'artifacts' || visArg === 'metadata'
428
+ ? visArg
429
+ : undefined;
430
+ try {
431
+ await handleShareCommand({
432
+ runId,
433
+ visibility,
434
+ baseUrl: values['bad-app-url'],
435
+ apiKey: values['api-key'],
436
+ noCopy: values['no-copy'],
437
+ json: values.json,
438
+ });
439
+ process.exit(0);
440
+ }
441
+ catch (err) {
442
+ if (err instanceof ShareError) {
443
+ cliError(err.message);
444
+ process.exit(1);
445
+ }
446
+ throw err;
447
+ }
448
+ }
449
+ if (command !== 'run' && command !== 'attach') {
450
+ cliError(`Unknown command: ${command}. Use "run", "attach", "preview", "runs", "view", "share", "chrome-debug", "design-audit", "showcase", or "auth".`);
356
451
  process.exit(1);
357
452
  }
358
453
  // Validate inputs
359
- if (!values.goal && !values.cases && !values['resume-run'] && !values['fork-run']) {
360
- cliError('provide --goal "..." --url "..." for a single task, --cases ./cases.json for a suite, or --resume-run / --fork-run <runId>.');
454
+ if (!values.goal && !values.cases && !values['cases-json'] && !values['resume-run'] && !values['fork-run']) {
455
+ cliError('provide --goal "..." --url "..." for a single task, --cases ./cases.json (or --cases-json \'[...]\') for a suite, or --resume-run / --fork-run <runId>.');
361
456
  process.exit(1);
362
457
  }
363
458
  // Load config file, then overlay CLI flags
@@ -531,6 +626,40 @@ async function main() {
531
626
  cliOverrides.profileDir = values['profile-dir'];
532
627
  if (values['cdp-url'])
533
628
  cliOverrides.cdpUrl = values['cdp-url'];
629
+ // --attach resolves to cdpUrl by probing a running Chrome's DevTools
630
+ // endpoint. Done here (pre-config-merge) so the existing cdpUrl path at
631
+ // cli.ts:916 takes over unchanged. Conflicts with wallet/extension/profile
632
+ // flags are surfaced up-front rather than silently ignored.
633
+ if (values.attach) {
634
+ if (values['cdp-url']) {
635
+ cliError('--attach and --cdp-url are mutually exclusive (both select a CDP endpoint). Use one.');
636
+ process.exit(1);
637
+ }
638
+ const { resolveAttachEndpoint, validateAttachConflicts } = await import('./cli-attach.js');
639
+ const conflicts = validateAttachConflicts({
640
+ walletEnabled: Boolean(cliOverrides.wallet?.enabled) || Boolean(values.extension?.length),
641
+ profileDir: values['profile-dir'],
642
+ extensionPaths: values.extension,
643
+ userDataDir: values['user-data-dir'],
644
+ });
645
+ if (!conflicts.ok) {
646
+ for (const err of conflicts.errors)
647
+ cliError(err);
648
+ process.exit(1);
649
+ }
650
+ const port = values['attach-port'] ? parseInt(values['attach-port'], 10) : undefined;
651
+ if (port !== undefined && !Number.isFinite(port)) {
652
+ cliError(`Invalid --attach-port value: ${values['attach-port']}`);
653
+ process.exit(1);
654
+ }
655
+ const info = await resolveAttachEndpoint({ port }).catch((err) => {
656
+ cliError(err instanceof Error ? err.message : String(err));
657
+ process.exit(1);
658
+ });
659
+ cliOverrides.cdpUrl = info.webSocketDebuggerUrl;
660
+ if (!values.quiet)
661
+ cliLog('attach', `connected to ${info.browser ?? 'Chrome'}`);
662
+ }
534
663
  if (values.memory !== undefined || values['memory-dir']) {
535
664
  cliOverrides.memory = {
536
665
  ...(cliOverrides.memory ?? {}),
@@ -694,9 +823,13 @@ async function main() {
694
823
  // Optional live event bus + SSE viewer. Constructed only when `--live` is
695
824
  // passed; otherwise the runner uses an internal no-op bus and pays nothing.
696
825
  // The bus is shared across the entire suite so a single SSE connection
697
- // observes all turns of all test cases.
826
+ // observes all turns of all test cases. Gen 32: when `--stream <url>` is
827
+ // passed the bus is always created (even without --live) so the webhook
828
+ // streamer has something to subscribe to.
698
829
  const liveEnabled = values.live === true;
699
- const liveBus = liveEnabled ? new TurnEventBus() : undefined;
830
+ const streamUrl = typeof values.stream === 'string' && values.stream.length > 0 ? values.stream : undefined;
831
+ const needsBus = liveEnabled || !!streamUrl;
832
+ const liveBus = needsBus ? new TurnEventBus() : undefined;
700
833
  const liveCancelController = liveEnabled ? new AbortController() : undefined;
701
834
  let liveViewHandle;
702
835
  if (liveEnabled && liveBus) {
@@ -708,6 +841,39 @@ async function main() {
708
841
  noOpen: values['no-open'] === true,
709
842
  });
710
843
  }
844
+ // Gen 32 — webhook streamer. When `--stream <url>` is passed, subscribe
845
+ // to the bus and POST every event to <url> as it fires. Auth via
846
+ // `--stream-token` or $BAD_STREAM_TOKEN. Non-fatal on failure — the
847
+ // canonical record is always events.jsonl on disk.
848
+ let webhookStreamer;
849
+ if (streamUrl && liveBus) {
850
+ const { WebhookStreamer } = await import('./runner/stream-webhook.js');
851
+ const token = values['stream-token'] || process.env.BAD_STREAM_TOKEN;
852
+ const streamId = `stream_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
853
+ webhookStreamer = new WebhookStreamer({
854
+ url: streamUrl,
855
+ authToken: token,
856
+ streamId,
857
+ onError: (err, dropped) => {
858
+ if (!values.json)
859
+ cliWarn(`stream: ${err.message} (dropped ${dropped} event${dropped === 1 ? '' : 's'})`);
860
+ },
861
+ }).attach(liveBus);
862
+ if (!values.json)
863
+ cliLog('stream', `POST → ${streamUrl} (id ${streamId})`);
864
+ }
865
+ // Gen 32 — interrupt controller. `--interrupt` enables keyboard pause/
866
+ // resume/abort during a run. No-op in non-TTY (CI, piped output).
867
+ let interruptController;
868
+ let detachInterrupt;
869
+ if (values.interrupt === true && process.stdin.isTTY) {
870
+ const { InterruptController } = await import('./runner/interrupt-controller.js');
871
+ interruptController = new InterruptController({
872
+ onStatus: (msg) => { if (!values.json)
873
+ cliLog('interrupt', msg); },
874
+ });
875
+ detachInterrupt = interruptController.attach();
876
+ }
711
877
  // Auto-discover bad.config.{ts,mjs,js,...} from cwd plus any explicit
712
878
  // --extension paths. Failures are reported but never fatal: a broken user
713
879
  // config should warn, not abort the run.
@@ -717,6 +883,27 @@ async function main() {
717
883
  : explicitExtPaths
718
884
  ? [explicitExtPaths]
719
885
  : [];
886
+ // Domain skills: markdown-based per-host rule libraries under
887
+ // skills/domain/<host>/SKILL.md. They plumb into the same BadExtension
888
+ // pipeline as user `bad.config.mjs` files via addRulesForDomain, so the
889
+ // existing setExtensionRules injection at brain/index.ts:899 picks them up
890
+ // with no second injection site. Gated by BAD_DOMAIN_SKILLS_DISABLED=1.
891
+ const domainSkillsDisabled = process.env.BAD_DOMAIN_SKILLS_DISABLED === '1';
892
+ let domainSkillExtension;
893
+ let domainSkillsLoadedCount = 0;
894
+ if (!domainSkillsDisabled) {
895
+ const { loadDomainSkills, buildDomainSkillExtension } = await import('./skills/domain-loader.js');
896
+ const domainLoad = await loadDomainSkills();
897
+ if (domainLoad.skills.length > 0) {
898
+ domainSkillExtension = buildDomainSkillExtension(domainLoad.skills);
899
+ domainSkillsLoadedCount = domainLoad.skills.length;
900
+ }
901
+ if (domainLoad.errors.length > 0 && !values.json) {
902
+ for (const err of domainLoad.errors) {
903
+ cliWarn(`domain-skill load failed: ${err.path} — ${err.error}`);
904
+ }
905
+ }
906
+ }
720
907
  const extensionLoad = await loadExtensions({ explicitPaths: explicitExtArr });
721
908
  if (extensionLoad.loadedFrom.length > 0 && !values.json) {
722
909
  cliLog('extensions', `loaded ${extensionLoad.loadedFrom.length}: ${extensionLoad.loadedFrom.join(', ')}`);
@@ -726,6 +913,35 @@ async function main() {
726
913
  cliWarn(`extension load failed: ${err.path} — ${err.error}`);
727
914
  }
728
915
  }
916
+ if (domainSkillExtension && domainSkillsLoadedCount > 0) {
917
+ // Re-resolve the bundle with the domain-skill synthetic extension
918
+ // merged in. Resolving twice is cheap (the list is short) and keeps
919
+ // domainSkillExtension from having to plumb through a separate wire.
920
+ const { resolveExtensions } = await import('./extensions/types.js');
921
+ const combined = resolveExtensions([...extensionLoad.resolved.extensions, domainSkillExtension]);
922
+ extensionLoad.resolved = combined;
923
+ if (!values.json)
924
+ cliLog('skills', `domain: ${domainSkillsLoadedCount} loaded`);
925
+ }
926
+ // Gen 29: macros. Loaded alongside domain skills; gated by
927
+ // BAD_MACROS_DISABLED=1. The registry is passed to PlaywrightDriver
928
+ // (dispatch) and its promptBlock to the BrowserAgent (visibility).
929
+ const macrosDisabled = process.env.BAD_MACROS_DISABLED === '1';
930
+ let macroRegistry;
931
+ if (!macrosDisabled) {
932
+ const { loadMacros, buildMacroRegistry } = await import('./skills/macro-loader.js');
933
+ const macroLoad = await loadMacros();
934
+ if (macroLoad.errors.length > 0 && !values.json) {
935
+ for (const err of macroLoad.errors) {
936
+ cliWarn(`macro load failed: ${err.path} — ${err.error}`);
937
+ }
938
+ }
939
+ if (macroLoad.macros.length > 0) {
940
+ macroRegistry = buildMacroRegistry(macroLoad.macros);
941
+ if (!values.json)
942
+ cliLog('skills', `macros: ${macroLoad.macros.length} loaded`);
943
+ }
944
+ }
729
945
  const concurrency = launchPlan.concurrency;
730
946
  const maxTurns = driverConfig.maxTurns ?? 30;
731
947
  const screenshotInterval = driverConfig.screenshotInterval ?? 5;
@@ -807,8 +1023,8 @@ async function main() {
807
1023
  parentRunId: scenario.parentRunId,
808
1024
  }];
809
1025
  }
810
- else if (values.cases) {
811
- const raw = fs.readFileSync(path.resolve(values.cases), 'utf-8');
1026
+ else if (values['cases-json'] || values.cases) {
1027
+ const raw = values['cases-json'] || fs.readFileSync(path.resolve(values.cases), 'utf-8');
812
1028
  const parsed = JSON.parse(raw);
813
1029
  const rawCases = Array.isArray(parsed) ? parsed : [parsed];
814
1030
  // Ensure required fields — spread raw case first so explicit fields become defaults
@@ -943,7 +1159,7 @@ async function main() {
943
1159
  fs.mkdirSync(userDataDir, { recursive: true });
944
1160
  const persistentLaunchStartedAt = Date.now();
945
1161
  persistentContext = await chromium.launchPersistentContext(userDataDir, {
946
- channel: 'chrome',
1162
+ channel: isStealthProfile ? 'chrome' : 'chromium',
947
1163
  headless: launchPlan.headless,
948
1164
  args: launchPlan.browserArgs,
949
1165
  viewport,
@@ -1088,10 +1304,11 @@ async function main() {
1088
1304
  browser = await browserType.launch({
1089
1305
  headless: launchPlan.headless,
1090
1306
  ...(browserName === 'chromium' ? { args: launchPlan.browserArgs } : {}),
1091
- // Use system Chrome for all Chromium runs — real TLS/JA3 fingerprint
1092
- // vs bundled Chromium. Bundled Chromium's TLS handshake is detectably
1093
- // different from real Chrome, causing 403s on Cloudflare/Akamai sites.
1094
- ...(browserName === 'chromium' ? { channel: 'chrome' } : {}),
1307
+ // System Chrome for stealth profiles only — real TLS/JA3 fingerprint
1308
+ // fixes anti-bot blocking. But system Chrome renders differently than
1309
+ // bundled Chromium on some sites (Allrecipes click timeouts, Amazon
1310
+ // layout shifts), so only enable when anti-bot evasion is needed.
1311
+ ...(isStealthProfile && browserName === 'chromium' ? { channel: 'chrome' } : {}),
1095
1312
  // Residential/SOCKS5/HTTP proxy — routes all traffic through the proxy
1096
1313
  ...(launchPlan.proxyServer ? { proxy: { server: launchPlan.proxyServer } } : {}),
1097
1314
  });
@@ -1277,6 +1494,7 @@ async function main() {
1277
1494
  visionStrategy: config.visionStrategy,
1278
1495
  screenshotInterval,
1279
1496
  showCursor: values['show-cursor'],
1497
+ ...(macroRegistry ? { macros: macroRegistry } : {}),
1280
1498
  });
1281
1499
  // Apply resource blocking if configured
1282
1500
  const resourceBlockingStartedAt = Date.now();
@@ -1333,7 +1551,11 @@ async function main() {
1333
1551
  screenshotInterval,
1334
1552
  artifactSink: sink,
1335
1553
  extensions: extensionLoad.resolved,
1554
+ ...(macroRegistry ? { macroPromptBlock: macroRegistry.promptBlock } : {}),
1336
1555
  ...(liveBus ? { eventBus: liveBus } : {}),
1556
+ ...(interruptController
1557
+ ? { beforeTurn: async () => { await interruptController.waitIfPaused(); } }
1558
+ : {}),
1337
1559
  onProgress: (event) => {
1338
1560
  if (values.json) {
1339
1561
  console.log(JSON.stringify(event));
@@ -1363,7 +1585,23 @@ async function main() {
1363
1585
  let result;
1364
1586
  let runError;
1365
1587
  try {
1366
- result = await runner.runSuite(cases, liveCancelController ? { signal: liveCancelController.signal } : undefined);
1588
+ // Gen 32 wire interrupt controller. When the user presses `q`,
1589
+ // abort() fires which the runner observes via the suite-level
1590
+ // signal. Runs a check before the runner starts so pressing `q`
1591
+ // BEFORE the first turn still aborts cleanly.
1592
+ const interruptSignal = interruptController
1593
+ ? (() => {
1594
+ const ac = new AbortController();
1595
+ interruptController.on('abort', () => ac.abort('interrupted by user'));
1596
+ if (interruptController.isAborted)
1597
+ ac.abort('interrupted by user');
1598
+ return ac.signal;
1599
+ })()
1600
+ : undefined;
1601
+ // If --live already provided a signal, merge both. Prefer the user
1602
+ // interrupt signal so `q` wins even when --live is set.
1603
+ const mergedSignal = interruptSignal ?? liveCancelController?.signal;
1604
+ result = await runner.runSuite(cases, mergedSignal ? { signal: mergedSignal } : undefined);
1367
1605
  // Write reports for each configured format
1368
1606
  const { generateReport } = await import('./test-report.js');
1369
1607
  const reporters = driverConfig.reporters ?? ['json'];
@@ -1399,6 +1637,16 @@ async function main() {
1399
1637
  }
1400
1638
  finally {
1401
1639
  renderer?.destroy();
1640
+ // Gen 32 — detach interrupt controller first so stdin leaves raw mode
1641
+ // BEFORE we try to write any shutdown logs. Otherwise the user's TTY
1642
+ // stays in raw mode after the run ends and Ctrl-C, arrow keys, etc.
1643
+ // come through as garbage bytes.
1644
+ if (detachInterrupt)
1645
+ detachInterrupt();
1646
+ // Flush and close the stream webhook so the final events (run-completed,
1647
+ // suite-complete) reach the endpoint before the CLI exits.
1648
+ if (webhookStreamer)
1649
+ await webhookStreamer.close().catch(() => { });
1402
1650
  await singleDriver?.close?.().catch(() => { });
1403
1651
  stopWalletAutoApprover?.();
1404
1652
  await persistentContext?.close().catch(() => { });