neoagent 3.0.1-beta.9 → 3.1.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 (97) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.js +436 -10
  42. package/server/admin/billing.js +158 -35
  43. package/server/admin/index.html +72 -2
  44. package/server/admin/users.js +15 -15
  45. package/server/db/database.js +125 -20
  46. package/server/http/routes.js +1 -0
  47. package/server/http/static.js +4 -2
  48. package/server/public/.last_build_id +1 -1
  49. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  50. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  51. package/server/public/canvaskit/wimp.wasm +0 -0
  52. package/server/public/flutter_bootstrap.js +2 -2
  53. package/server/public/main.dart.js +91077 -87037
  54. package/server/routes/account.js +53 -0
  55. package/server/routes/admin.js +345 -64
  56. package/server/routes/agents.js +203 -21
  57. package/server/routes/billing.js +5 -0
  58. package/server/routes/browser.js +8 -1
  59. package/server/routes/recordings.js +96 -6
  60. package/server/routes/screenHistory.js +140 -2
  61. package/server/routes/timeline.js +43 -0
  62. package/server/services/account/erasure.js +263 -0
  63. package/server/services/ai/hooks.js +4 -1
  64. package/server/services/ai/loop/agent_engine_core.js +8 -1
  65. package/server/services/ai/loop/blank_recovery.js +36 -0
  66. package/server/services/ai/loop/conversation_loop.js +166 -33
  67. package/server/services/ai/messagingFallback.js +22 -0
  68. package/server/services/ai/rate_limits.js +28 -5
  69. package/server/services/ai/systemPrompt.js +6 -5
  70. package/server/services/ai/taskAnalysis.js +2 -0
  71. package/server/services/ai/toolEvidence.js +15 -0
  72. package/server/services/ai/toolResult.js +40 -0
  73. package/server/services/ai/tools.js +163 -4
  74. package/server/services/android/controller.js +6 -2
  75. package/server/services/billing/plans.js +2 -1
  76. package/server/services/browser/anti_detection.js +192 -0
  77. package/server/services/browser/controller.js +180 -54
  78. package/server/services/desktop/auth.js +3 -0
  79. package/server/services/desktop/registry.js +50 -2
  80. package/server/services/integrations/google/calendar.js +22 -14
  81. package/server/services/manager.js +12 -42
  82. package/server/services/memory/ingestion_chunking.js +268 -0
  83. package/server/services/messaging/telnyx.js +9 -8
  84. package/server/services/recordings/manager.js +60 -27
  85. package/server/services/runtime/docker-vm-manager.js +157 -266
  86. package/server/services/runtime/guest_bootstrap.js +17 -5
  87. package/server/services/runtime/guest_image.js +188 -0
  88. package/server/services/runtime/manager.js +0 -1
  89. package/server/services/runtime/validation.js +3 -8
  90. package/server/services/social_video/service.js +60 -10
  91. package/server/services/tasks/runtime.js +234 -9
  92. package/server/services/tasks/task_repository.js +13 -0
  93. package/server/services/timeline/service.js +558 -0
  94. package/server/services/wearable/gateway.js +1 -1
  95. package/server/services/websocket.js +21 -3
  96. package/server/services/desktop/screenRecorder.js +0 -292
  97. package/server/services/desktop/screen_recorder_support.js +0 -46
package/lib/manager.js CHANGED
@@ -1432,81 +1432,43 @@ function startFallback() {
1432
1432
  logOk(`Started detached process (pid ${child.pid})`);
1433
1433
  }
1434
1434
 
1435
- async function ensureQemuInstalled({ required = false } = {}) {
1436
- heading('Ensure QEMU Installed');
1435
+ // The isolated browser/CLI runtime runs each user's guest agent in a Docker
1436
+ // container built from a baked image (browser + dependencies installed at build
1437
+ // time). Docker cannot be auto-installed safely across platforms, so when it is
1438
+ // missing we surface clear guidance; when it is present we pre-build the guest
1439
+ // image so the first browser/CLI action is instant rather than paying a one-time
1440
+ // multi-minute build on demand.
1441
+ async function ensureContainerRuntime({ required = false } = {}) {
1442
+ heading('Ensure Container Runtime');
1437
1443
  const platform = detectPlatform();
1438
-
1439
- const hasSystem = commandExists('qemu-system-x86_64') || commandExists('qemu-system-aarch64');
1440
- const hasImg = commandExists('qemu-img');
1441
1444
 
1442
- if (hasSystem && hasImg) {
1443
- logOk('QEMU components are already installed');
1444
- return;
1445
- }
1446
-
1447
- logInfo('QEMU components not found. Attempting to install...');
1445
+ const dockerReady = commandExists('docker')
1446
+ && spawnSync('docker', ['info'], { stdio: 'ignore', timeout: 10000 }).status === 0;
1448
1447
 
1449
- try {
1450
- if (platform === 'macos') {
1451
- if (!commandExists('brew')) {
1452
- const message = 'Homebrew is required for automatic QEMU install on macOS. Install Homebrew from https://brew.sh/ and run `neoagent install` again.';
1453
- if (required) throw new Error(message);
1454
- logWarn('Homebrew not found; VM features will stay unavailable until QEMU is installed.');
1455
- rememberInstallAction(message);
1456
- return false;
1457
- }
1458
- logInfo('Running "brew install qemu"...');
1459
- runOrThrow('brew', ['install', 'qemu']);
1460
- } else if (platform === 'linux') {
1461
- const isArm = process.arch === 'arm64' || process.arch === 'aarch64';
1462
- if (commandExists('apt-get')) {
1463
- const pkgs = ['qemu-utils'];
1464
- if (isArm) {
1465
- pkgs.push('qemu-system-arm');
1466
- } else {
1467
- pkgs.push('qemu-system-x86');
1468
- }
1469
- logInfo(`Running "sudo apt-get update && sudo apt-get install -y ${pkgs.join(' ')}"...`);
1470
- runOrThrow('sudo', ['apt-get', 'update']);
1471
- runOrThrow('sudo', ['apt-get', 'install', '-y', ...pkgs]);
1472
- } else if (commandExists('dnf')) {
1473
- logInfo('Running "sudo dnf install -y qemu-kvm qemu-img"...');
1474
- runOrThrow('sudo', ['dnf', 'install', '-y', 'qemu-kvm', 'qemu-img']);
1475
- } else if (commandExists('yum')) {
1476
- logInfo('Running "sudo yum install -y qemu-kvm qemu-img"...');
1477
- runOrThrow('sudo', ['yum', 'install', '-y', 'qemu-kvm', 'qemu-img']);
1478
- } else {
1479
- const message = 'Unsupported Linux package manager for automatic QEMU install. Install qemu-system and qemu-utils, then run `neoagent install` again.';
1480
- if (required) throw new Error(message);
1481
- logWarn('Could not find apt-get, dnf, or yum for QEMU installation.');
1482
- rememberInstallAction(message);
1483
- return false;
1484
- }
1485
- } else {
1486
- const message = 'Unsupported platform for automatic QEMU installation. Install QEMU manually if you need VM-isolated runtime features.';
1487
- if (required) throw new Error(message);
1488
- logWarn('Skipping QEMU auto-install on this platform.');
1489
- rememberInstallAction(message);
1490
- return false;
1491
- }
1492
- } catch (err) {
1493
- if (required) throw err;
1494
- logWarn(`QEMU install did not complete: ${err.message}`);
1495
- rememberInstallAction('Install QEMU manually or rerun `neoagent install` after your package manager is ready.');
1448
+ if (!dockerReady) {
1449
+ const installHint = platform === 'macos'
1450
+ ? 'Install Docker Desktop from https://www.docker.com/products/docker-desktop/ and start it.'
1451
+ : 'Install Docker Engine (https://docs.docker.com/engine/install/) and ensure the daemon is running.';
1452
+ const message = `Docker is required for the isolated browser/CLI runtime. ${installHint} Then run \`neoagent install\` again.`;
1453
+ if (required) throw new Error(message);
1454
+ logWarn('Docker not detected; the cloud browser and CLI will be unavailable until Docker is installed and running.');
1455
+ rememberInstallAction(message);
1496
1456
  return false;
1497
1457
  }
1498
1458
 
1499
- const verifiedSystem = commandExists('qemu-system-x86_64') || commandExists('qemu-system-aarch64');
1500
- const verifiedImg = commandExists('qemu-img');
1459
+ logOk('Docker is installed and the daemon is running');
1501
1460
 
1502
- if (verifiedSystem && verifiedImg) {
1503
- logOk('QEMU installed successfully');
1461
+ try {
1462
+ logInfo('Building the guest runtime image (one-time; downloads the browser and dependencies)…');
1463
+ const { GuestImageBuilder } = require('../server/services/runtime/guest_image');
1464
+ await new GuestImageBuilder({ runtimeProfile: 'browser_cli' }).ensure();
1465
+ logOk('Guest runtime image is ready');
1504
1466
  return true;
1505
- } else {
1506
- const message = 'QEMU installation finished but required binaries were not found in PATH.';
1467
+ } catch (err) {
1468
+ const message = `Guest runtime image build did not complete: ${err.message}`;
1507
1469
  if (required) throw new Error(message);
1508
1470
  logWarn(message);
1509
- rememberInstallAction('Ensure qemu-system and qemu-img are available in PATH, then run `neoagent install` again.');
1471
+ rememberInstallAction('Rerun `neoagent install` to retry building the guest runtime image, or it will build automatically on first use.');
1510
1472
  return false;
1511
1473
  }
1512
1474
  }
@@ -1585,7 +1547,7 @@ async function cmdInstall() {
1585
1547
  }
1586
1548
 
1587
1549
  installDependencies();
1588
- await ensureQemuInstalled({ required: false });
1550
+ await ensureContainerRuntime({ required: false });
1589
1551
  ensureYtDlpInstalled();
1590
1552
  buildBundledWebClientIfPossible({ required: true });
1591
1553
 
@@ -2000,6 +1962,152 @@ function cmdVersion() {
2000
1962
  console.log(currentInstalledVersionLabel());
2001
1963
  }
2002
1964
 
1965
+ async function cmdBilling(args = []) {
1966
+ const sub = (args[0] || 'status').toLowerCase();
1967
+
1968
+ if (sub === '--help' || sub === '-h' || sub === 'help') {
1969
+ console.log('\nNeoAgent Billing');
1970
+ console.log('Usage: neoagent billing [subcommand]');
1971
+ console.log('');
1972
+ console.log('Subcommands:');
1973
+ console.log(' neoagent billing Show billing configuration status');
1974
+ console.log(' neoagent billing setup Interactive Stripe setup wizard');
1975
+ console.log(' neoagent billing enable Enable billing and restart');
1976
+ console.log(' neoagent billing disable Disable billing and restart');
1977
+ console.log('');
1978
+ return;
1979
+ }
1980
+
1981
+ if (sub === 'status') {
1982
+ cliBanner('Billing', 'configuration status');
1983
+ heading('Billing Status');
1984
+ const env = Object.fromEntries(parseEnv(readEnvFileRaw()).entries());
1985
+ const enabled = ['1', 'true', 'yes'].includes(String(env.NEOAGENT_BILLING_ENABLED || '').toLowerCase());
1986
+ const hasSecret = Boolean(env.STRIPE_SECRET_KEY);
1987
+ const hasPub = Boolean(env.STRIPE_PUBLISHABLE_KEY);
1988
+ const hasWebhook = Boolean(env.STRIPE_WEBHOOK_SECRET);
1989
+ const trialDays = env.BILLING_TRIAL_DAYS || '14';
1990
+
1991
+ cliSection('Billing config');
1992
+ statusLine(enabled, 'enabled', enabled ? 'yes' : 'no', enabled ? '' : 'run: neoagent billing enable');
1993
+ statusLine(hasSecret, 'secret', hasSecret ? maskEnvValue('SECRET', env.STRIPE_SECRET_KEY) : 'not set', hasSecret ? '' : 'run: neoagent billing setup');
1994
+ statusLine(hasPub, 'public', hasPub ? maskEnvValue('KEY', env.STRIPE_PUBLISHABLE_KEY) : 'not set');
1995
+ statusLine(hasWebhook, 'webhook', hasWebhook ? 'configured' : 'not set');
1996
+ console.log('');
1997
+ cliSection('Trial');
1998
+ console.log(` trial days ${trialDays}`);
1999
+
2000
+ const port = loadEnvPort();
2001
+ const publicUrl = env.PUBLIC_URL || `http://localhost:${port}`;
2002
+ console.log('');
2003
+ cliSection('Webhook endpoint');
2004
+ console.log(` ${publicUrl}/api/billing/webhook`);
2005
+ console.log('');
2006
+
2007
+ if (!hasSecret || !hasPub) {
2008
+ logWarn('Stripe keys not configured. Run `neoagent billing setup` to get started.');
2009
+ } else if (!enabled) {
2010
+ logInfo('Billing is configured but not enabled. Run `neoagent billing enable` to activate.');
2011
+ } else {
2012
+ logOk('Billing is active. Manage plans in Admin › Billing.');
2013
+ }
2014
+ return;
2015
+ }
2016
+
2017
+ if (sub === 'enable') {
2018
+ heading('Enable Billing');
2019
+ upsertEnvValue('NEOAGENT_BILLING_ENABLED', 'true');
2020
+ logOk('Set NEOAGENT_BILLING_ENABLED=true');
2021
+ logInfo('Restarting NeoAgent to apply changes...');
2022
+ cmdRestart();
2023
+ return;
2024
+ }
2025
+
2026
+ if (sub === 'disable') {
2027
+ heading('Disable Billing');
2028
+ upsertEnvValue('NEOAGENT_BILLING_ENABLED', 'false');
2029
+ logOk('Set NEOAGENT_BILLING_ENABLED=false');
2030
+ logInfo('Restarting NeoAgent to apply changes...');
2031
+ cmdRestart();
2032
+ return;
2033
+ }
2034
+
2035
+ if (sub === 'setup') {
2036
+ cliBanner('Billing Setup', 'Stripe configuration');
2037
+ heading('Billing Setup');
2038
+ ensureRuntimeDirs();
2039
+
2040
+ const current = Object.fromEntries(parseEnv(readEnvFileRaw()).entries());
2041
+ const port = loadEnvPort();
2042
+ const publicUrl = current.PUBLIC_URL || `http://localhost:${port}`;
2043
+
2044
+ logInfo('Press Enter to keep the current value shown in brackets.');
2045
+ logInfo(`Webhook endpoint: ${publicUrl}/api/billing/webhook`);
2046
+ console.log('');
2047
+
2048
+ heading('Stripe API keys');
2049
+ logInfo('Find these in your Stripe dashboard: https://dashboard.stripe.com/apikeys');
2050
+ const secretKey = await askSecret('Stripe secret key (sk_live_... or sk_test_...)', current.STRIPE_SECRET_KEY || '');
2051
+ const publishableKey = await askSecret('Stripe publishable key (pk_live_... or pk_test_...)', current.STRIPE_PUBLISHABLE_KEY || '');
2052
+
2053
+ if (secretKey && !secretKey.startsWith('sk_')) {
2054
+ logWarn('Secret key does not start with "sk_" — double-check the value.');
2055
+ }
2056
+ if (publishableKey && !publishableKey.startsWith('pk_')) {
2057
+ logWarn('Publishable key does not start with "pk_" — double-check the value.');
2058
+ }
2059
+
2060
+ heading('Webhook');
2061
+ logInfo(`Register this endpoint in Stripe: ${publicUrl}/api/billing/webhook`);
2062
+ logInfo('Required events: customer.subscription.*, invoice.payment_succeeded, invoice.payment_failed');
2063
+ const webhookSecret = await askSecret('Stripe webhook signing secret (whsec_...)', current.STRIPE_WEBHOOK_SECRET || '');
2064
+
2065
+ if (webhookSecret && !webhookSecret.startsWith('whsec_')) {
2066
+ logWarn('Webhook secret does not start with "whsec_" — double-check the value.');
2067
+ }
2068
+
2069
+ heading('Trial');
2070
+ const trialDaysRaw = await ask('Free trial length in days (0 to disable)', current.BILLING_TRIAL_DAYS || '14');
2071
+ const trialDays = Number(trialDaysRaw);
2072
+ if (!Number.isInteger(trialDays) || trialDays < 0) {
2073
+ throw new Error(`Invalid trial length "${trialDaysRaw}". Must be a non-negative integer.`);
2074
+ }
2075
+
2076
+ if (secretKey) upsertEnvValue('STRIPE_SECRET_KEY', secretKey);
2077
+ if (publishableKey) upsertEnvValue('STRIPE_PUBLISHABLE_KEY', publishableKey);
2078
+ if (webhookSecret) upsertEnvValue('STRIPE_WEBHOOK_SECRET', webhookSecret);
2079
+ upsertEnvValue('BILLING_TRIAL_DAYS', String(trialDays));
2080
+
2081
+ logOk('Stripe credentials saved to .env');
2082
+
2083
+ const isAlreadyEnabled = ['1', 'true', 'yes'].includes(String(current.NEOAGENT_BILLING_ENABLED || '').toLowerCase());
2084
+ let enableNow = isAlreadyEnabled;
2085
+ if (!isAlreadyEnabled) {
2086
+ const enableAnswer = await ask('Enable billing now? (y/N)', 'N');
2087
+ enableNow = enableAnswer.toLowerCase() === 'y' || enableAnswer.toLowerCase() === 'yes';
2088
+ }
2089
+
2090
+ if (enableNow) {
2091
+ upsertEnvValue('NEOAGENT_BILLING_ENABLED', 'true');
2092
+ logOk('Billing enabled');
2093
+ logInfo('Restarting NeoAgent to apply credentials...');
2094
+ cmdRestart();
2095
+ } else {
2096
+ logInfo('Billing is not yet enabled. Run `neoagent billing enable` when ready.');
2097
+ }
2098
+
2099
+ console.log('');
2100
+ heading('Next steps');
2101
+ logInfo('1. Verify the webhook endpoint is reachable from Stripe');
2102
+ logInfo('2. Create subscription plans in Admin › Billing › Plans');
2103
+ logInfo('3. Add a free plan (price = 0) for users without a paid subscription');
2104
+ logInfo(' See: neoagent billing status');
2105
+ return;
2106
+ }
2107
+
2108
+ throw new Error(`Unknown billing subcommand: ${sub}. Run "neoagent billing --help" for usage.`);
2109
+ }
2110
+
2003
2111
  function printHelp() {
2004
2112
  const c = COLORS;
2005
2113
  const W = 38;
@@ -2047,6 +2155,13 @@ function printHelp() {
2047
2155
  row('admin', 'Show admin dashboard URL and credentials');
2048
2156
  console.log('');
2049
2157
 
2158
+ cliSection('Billing');
2159
+ row('billing', 'Show billing configuration status');
2160
+ row('billing setup', 'Interactive Stripe setup wizard');
2161
+ row('billing enable', 'Enable billing and restart');
2162
+ row('billing disable', 'Disable billing and restart');
2163
+ console.log('');
2164
+
2050
2165
  cliSection('Maintenance');
2051
2166
  row('migrate', 'Migrate from another agent installation');
2052
2167
  row('migrate dry-run', 'Preview what would be migrated');
@@ -2106,6 +2221,9 @@ async function runCLI(argv) {
2106
2221
  case 'login':
2107
2222
  await cmdLogin(argv.slice(1));
2108
2223
  break;
2224
+ case 'billing':
2225
+ await cmdBilling(argv.slice(1));
2226
+ break;
2109
2227
  case 'admin': {
2110
2228
  cliBanner('Admin Dashboard', 'credentials');
2111
2229
  const adminCreds = readAdminCredentials();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.0.1-beta.9",
3
+ "version": "3.1.0",
4
4
  "description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -51,6 +51,9 @@
51
51
  "flutter:test": "cd flutter_app && flutter test ../test/flutter/unit ../test/flutter/widget",
52
52
  "flutter:test:unit": "cd flutter_app && flutter test ../test/flutter/unit",
53
53
  "flutter:test:widget": "cd flutter_app && flutter test ../test/flutter/widget",
54
+ "benchmark:setup": "node benchmark/cli.js setup",
55
+ "benchmark:run": "node benchmark/cli.js run",
56
+ "benchmark:report": "node benchmark/cli.js report",
54
57
  "benchmark:tokens": "node scripts/benchmark-token-cost.js",
55
58
  "benchmark:memory": "node scripts/benchmark-memory.js",
56
59
  "benchmark:memorybench:install": "node scripts/install-memorybench-provider.js",
@@ -113,12 +113,12 @@ async function setSignup(enabled) {
113
113
  }
114
114
 
115
115
  async function rotateApiKey(btn) {
116
- if (!confirm(
117
- 'Rotate admin API key?\n\n' +
118
- 'The current key will stop working immediately.\n' +
119
- 'Any scripts using it must be updated.\n\n' +
120
- 'The new key will be shown once — copy it before leaving.'
121
- )) return;
116
+ if (!await showConfirmModal({
117
+ title: 'Rotate admin API key?',
118
+ body: 'The current key will stop working immediately. Any scripts using it must be updated.<br><br>The new key will be shown once — copy it before leaving.',
119
+ confirmLabel: 'Rotate Key',
120
+ confirmClass: 'btn-danger',
121
+ })) return;
122
122
  btn.disabled = true;
123
123
  btn.textContent = 'Rotating…';
124
124
  try {
@@ -140,7 +140,12 @@ async function rotateApiKey(btn) {
140
140
  }
141
141
 
142
142
  async function revokeApiKey(btn) {
143
- if (!confirm('Revoke admin API key?\n\nAll programmatic access using this key will stop immediately.')) return;
143
+ if (!await showConfirmModal({
144
+ title: 'Revoke admin API key?',
145
+ body: 'All programmatic access using this key will stop immediately.',
146
+ confirmLabel: 'Revoke Key',
147
+ confirmClass: 'btn-danger',
148
+ })) return;
144
149
  btn.disabled = true;
145
150
  btn.textContent = 'Revoking…';
146
151
  _revealedKey = null;