neoagent 2.4.1-beta.8 → 2.4.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.
Files changed (248) hide show
  1. package/.env.example +33 -3
  2. package/LICENSE +111 -56
  3. package/README.md +8 -3
  4. package/docs/configuration.md +8 -0
  5. package/docs/getting-started.md +9 -3
  6. package/docs/index.md +4 -0
  7. package/extensions/chrome-browser/background.mjs +45 -7
  8. package/extensions/chrome-browser/icons/icon128.png +0 -0
  9. package/extensions/chrome-browser/icons/icon16.png +0 -0
  10. package/extensions/chrome-browser/icons/icon48.png +0 -0
  11. package/extensions/chrome-browser/icons/logo.svg +39 -8
  12. package/extensions/chrome-browser/manifest.json +3 -2
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +15 -2
  15. package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
  16. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  17. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  18. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  19. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  20. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  21. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  22. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  23. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  24. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  25. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  26. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  27. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  28. package/flutter_app/assets/branding/app_icon_light_1024.png +0 -0
  29. package/flutter_app/assets/branding/app_icon_light_128.png +0 -0
  30. package/flutter_app/assets/branding/app_icon_light_192.png +0 -0
  31. package/flutter_app/assets/branding/app_icon_light_256.png +0 -0
  32. package/flutter_app/assets/branding/app_icon_light_32.png +0 -0
  33. package/flutter_app/assets/branding/app_icon_light_512.png +0 -0
  34. package/flutter_app/assets/branding/app_icon_light_64.png +0 -0
  35. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  36. package/flutter_app/assets/branding/tray_icon_light_template.png +0 -0
  37. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  38. package/flutter_app/lib/features/location/location_service.dart +3 -0
  39. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +391 -382
  40. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +743 -0
  41. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +18 -16
  42. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +19 -18
  43. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +8 -1
  44. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +16 -13
  45. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +17 -13
  46. package/flutter_app/lib/main.dart +3 -0
  47. package/flutter_app/lib/main_account_settings.dart +10 -34
  48. package/flutter_app/lib/main_admin.dart +14 -1
  49. package/flutter_app/lib/main_app_shell.dart +377 -340
  50. package/flutter_app/lib/main_chat.dart +707 -227
  51. package/flutter_app/lib/main_controller.dart +338 -46
  52. package/flutter_app/lib/main_devices.dart +851 -122
  53. package/flutter_app/lib/main_integrations.dart +255 -6
  54. package/flutter_app/lib/main_launcher.dart +1 -1
  55. package/flutter_app/lib/main_model_picker.dart +685 -0
  56. package/flutter_app/lib/main_models.dart +212 -0
  57. package/flutter_app/lib/main_navigation.dart +1 -9
  58. package/flutter_app/lib/main_operations.dart +1417 -614
  59. package/flutter_app/lib/main_settings.dart +764 -887
  60. package/flutter_app/lib/main_shared.dart +971 -443
  61. package/flutter_app/lib/main_spacing.dart +4 -4
  62. package/flutter_app/lib/main_theme.dart +22 -14
  63. package/flutter_app/lib/main_unified.dart +5 -72
  64. package/flutter_app/lib/src/android_apk_drop_zone.dart +24 -0
  65. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +13 -0
  66. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +219 -0
  67. package/flutter_app/lib/src/backend_client.dart +79 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +56 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +77 -1
  70. package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
  71. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  72. package/flutter_app/lib/src/stream_renderer.dart +205 -35
  73. package/flutter_app/lib/src/theme/palette.dart +76 -34
  74. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  75. package/flutter_app/macos/Runner/AppDelegate.swift +44 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  81. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  82. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  83. package/flutter_app/pubspec.lock +2 -2
  84. package/flutter_app/pubspec.yaml +7 -1
  85. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  94. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  95. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  96. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  97. package/flutter_app/web/favicon.png +0 -0
  98. package/flutter_app/web/favicon.svg +40 -9
  99. package/flutter_app/web/favicon_light.svg +43 -0
  100. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-192.png +0 -0
  102. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-512.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  106. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  107. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  108. package/flutter_app/windows/runner/main.cpp +7 -1
  109. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  110. package/lib/manager.js +445 -83
  111. package/package.json +17 -3
  112. package/runtime/paths.js +3 -1
  113. package/server/admin/access.js +198 -0
  114. package/server/admin/admin.css +268 -0
  115. package/server/admin/admin.js +348 -0
  116. package/server/admin/analytics.js +128 -0
  117. package/server/admin/index.html +1015 -0
  118. package/server/admin/login.html +290 -0
  119. package/server/admin/logo.svg +43 -0
  120. package/server/admin/sql.js +134 -0
  121. package/server/admin/users.js +147 -0
  122. package/server/config/origins.js +3 -1
  123. package/server/db/database.js +92 -0
  124. package/server/db/ftsQuery.js +27 -0
  125. package/server/http/routes.js +1 -0
  126. package/server/http/static.js +23 -6
  127. package/server/index.js +1 -1
  128. package/server/middleware/adminAuth.js +48 -0
  129. package/server/middleware/auth.js +1 -40
  130. package/server/public/.last_build_id +1 -1
  131. package/server/public/app_icon.png +0 -0
  132. package/server/public/assets/AssetManifest.bin +1 -1
  133. package/server/public/assets/AssetManifest.bin.json +1 -1
  134. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  138. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  139. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  140. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  141. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  142. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  143. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  144. package/server/public/assets/web/icons/Icon-192.png +0 -0
  145. package/server/public/favicon.png +0 -0
  146. package/server/public/favicon.svg +40 -9
  147. package/server/public/favicon_light.svg +43 -0
  148. package/server/public/flutter_bootstrap.js +2 -2
  149. package/server/public/icons/Icon-192-light.png +0 -0
  150. package/server/public/icons/Icon-192.png +0 -0
  151. package/server/public/icons/Icon-512-light.png +0 -0
  152. package/server/public/icons/Icon-512.png +0 -0
  153. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-192.png +0 -0
  155. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  156. package/server/public/icons/Icon-maskable-512.png +0 -0
  157. package/server/public/main.dart.js +88944 -85720
  158. package/server/routes/admin.js +632 -0
  159. package/server/routes/agent_profiles.js +3 -0
  160. package/server/routes/agents.js +1 -1
  161. package/server/routes/auth.js +3 -1
  162. package/server/routes/browser.js +14 -0
  163. package/server/routes/browser_extension.js +21 -4
  164. package/server/routes/desktop.js +10 -0
  165. package/server/routes/mcp.js +29 -13
  166. package/server/routes/memory.js +23 -1
  167. package/server/routes/messaging.js +2 -0
  168. package/server/routes/screenHistory.js +14 -8
  169. package/server/routes/settings.js +14 -2
  170. package/server/routes/stream.js +12 -3
  171. package/server/routes/triggers.js +4 -4
  172. package/server/routes/voice_assistant.js +36 -1
  173. package/server/routes/workspace.js +86 -0
  174. package/server/services/account/admin_two_factor.js +132 -0
  175. package/server/services/account/password_policy.js +6 -1
  176. package/server/services/ai/completion.js +44 -0
  177. package/server/services/ai/engine.js +321 -500
  178. package/server/services/ai/imageAnalysis.js +9 -5
  179. package/server/services/ai/logFormat.js +46 -0
  180. package/server/services/ai/loopPolicy.js +11 -0
  181. package/server/services/ai/messagingFallback.js +228 -0
  182. package/server/services/ai/models.js +194 -55
  183. package/server/services/ai/providerRetry.js +169 -0
  184. package/server/services/ai/providers/anthropic.js +15 -4
  185. package/server/services/ai/providers/google.js +38 -11
  186. package/server/services/ai/providers/grok.js +19 -68
  187. package/server/services/ai/providers/grokOauth.js +141 -0
  188. package/server/services/ai/providers/nvidia.js +154 -0
  189. package/server/services/ai/providers/ollama.js +76 -39
  190. package/server/services/ai/providers/openai.js +20 -31
  191. package/server/services/ai/providers/openaiCodex.js +6 -2
  192. package/server/services/ai/providers/openaiCompatible.js +70 -0
  193. package/server/services/ai/providers/openrouter.js +162 -0
  194. package/server/services/ai/settings.js +30 -0
  195. package/server/services/ai/systemPrompt.js +51 -29
  196. package/server/services/ai/taskAnalysis.js +60 -1
  197. package/server/services/ai/toolEvidence.js +207 -0
  198. package/server/services/ai/toolSelector.js +8 -1
  199. package/server/services/ai/tools.js +80 -15
  200. package/server/services/android/controller.js +36 -1
  201. package/server/services/browser/controller.js +18 -0
  202. package/server/services/browser/extension/manifest.js +33 -0
  203. package/server/services/browser/extension/provider.js +12 -6
  204. package/server/services/browser/extension/registry.js +229 -18
  205. package/server/services/desktop/protocol.js +1 -0
  206. package/server/services/desktop/provider.js +4 -0
  207. package/server/services/desktop/registry.js +104 -1
  208. package/server/services/desktop/screenRecorder.js +208 -98
  209. package/server/services/desktop/screen_recorder_support.js +46 -0
  210. package/server/services/integrations/google/provider.js +13 -0
  211. package/server/services/integrations/home_assistant/constants.js +88 -0
  212. package/server/services/integrations/home_assistant/network.js +207 -0
  213. package/server/services/integrations/home_assistant/provider.js +249 -0
  214. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  215. package/server/services/integrations/home_assistant/tools.js +101 -0
  216. package/server/services/integrations/manager.js +5 -0
  217. package/server/services/integrations/registry.js +2 -0
  218. package/server/services/integrations/trello/provider.js +8 -2
  219. package/server/services/manager.js +51 -47
  220. package/server/services/mcp/client.js +208 -268
  221. package/server/services/mcp/client_support.js +172 -0
  222. package/server/services/mcp/recovery.js +116 -0
  223. package/server/services/mcp/tool_operations.js +123 -0
  224. package/server/services/memory/ingestion.js +185 -370
  225. package/server/services/memory/ingestion_coverage.js +129 -0
  226. package/server/services/memory/ingestion_documents.js +123 -0
  227. package/server/services/memory/ingestion_support.js +171 -0
  228. package/server/services/memory/intelligence.js +181 -0
  229. package/server/services/memory/manager.js +476 -32
  230. package/server/services/messaging/automation.js +71 -134
  231. package/server/services/messaging/formatting_guides.js +26 -1
  232. package/server/services/messaging/inbound_queue.js +111 -0
  233. package/server/services/messaging/manager.js +25 -8
  234. package/server/services/messaging/typing_keepalive.js +110 -0
  235. package/server/services/runtime/manager.js +7 -3
  236. package/server/services/runtime/settings.js +17 -0
  237. package/server/services/streaming/android-stream.js +296 -32
  238. package/server/services/streaming/stream-hub.js +1 -1
  239. package/server/services/tasks/integration_runtime.js +4 -1
  240. package/server/services/tasks/runtime.js +415 -56
  241. package/server/services/tasks/task_repository.js +48 -6
  242. package/server/services/websocket.js +8 -2
  243. package/server/services/widgets/service.js +17 -1
  244. package/server/services/workspace/manager.js +116 -0
  245. package/server/utils/logger.js +44 -6
  246. package/server/utils/security.js +3 -0
  247. package/server/utils/version.js +29 -19
  248. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -1,53 +1,200 @@
1
1
  'use strict';
2
2
 
3
+ const path = require('path');
4
+ const { spawn } = require('child_process');
5
+ const { EventEmitter } = require('events');
3
6
  const sharp = require('sharp');
4
7
 
8
+ let H264_FIRST_FRAME_TIMEOUT_MS = 6_000;
9
+ const envTimeout = process.env.NEOAGENT_ANDROID_STREAM_FIRST_FRAME_TIMEOUT_MS;
10
+
11
+ if (envTimeout) {
12
+ const parsed = parseInt(envTimeout, 10);
13
+ if (!isNaN(parsed) && parsed > 0) {
14
+ H264_FIRST_FRAME_TIMEOUT_MS = parsed;
15
+ } else {
16
+ console.warn('[AndroidStream] Invalid NEOAGENT_ANDROID_STREAM_FIRST_FRAME_TIMEOUT_MS; using default 6000ms');
17
+ }
18
+ }
19
+
20
+ // Derive the full path to the `adb` binary the same way the Android controller does.
21
+ function resolveAdbBin(sdkDir) {
22
+ if (sdkDir) {
23
+ return path.join(sdkDir, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
24
+ }
25
+ return process.platform === 'win32' ? 'adb.exe' : 'adb'; // fall back to PATH
26
+ }
27
+
5
28
  function clampInt(value, fallback, min, max) {
6
29
  const parsed = Number(value);
7
30
  if (!Number.isFinite(parsed)) return fallback;
8
31
  return Math.min(max, Math.max(min, Math.floor(parsed)));
9
32
  }
10
33
 
34
+ // ---------------------------------------------------------------------------
35
+ // JpegFrameParser — extracts complete JPEG images from a raw byte stream.
36
+ // ffmpeg writes MJPEG to stdout as a continuous stream of JPEG frames, each
37
+ // delimited by SOI (FF D8) and EOI (FF D9) markers. We buffer incoming
38
+ // chunks and emit a 'frame' event for every complete JPEG found.
39
+ // ---------------------------------------------------------------------------
40
+ class JpegFrameParser extends EventEmitter {
41
+ constructor() {
42
+ super();
43
+ this._buf = null; // Buffer | null
44
+ }
45
+
46
+ push(chunk) {
47
+ if (!chunk?.length) return;
48
+ this._buf = this._buf ? Buffer.concat([this._buf, chunk]) : Buffer.from(chunk);
49
+ let start = 0;
50
+
51
+ while (start < this._buf.length - 1) {
52
+ // Locate SOI marker (0xFF 0xD8).
53
+ let soiIdx = -1;
54
+ for (let i = start; i < this._buf.length - 1; i++) {
55
+ if (this._buf[i] === 0xff && this._buf[i + 1] === 0xd8) {
56
+ soiIdx = i;
57
+ break;
58
+ }
59
+ }
60
+ if (soiIdx === -1) {
61
+ // No SOI found – discard everything.
62
+ this._buf = null;
63
+ return;
64
+ }
65
+
66
+ // Locate EOI marker (0xFF 0xD9) that comes after SOI.
67
+ let eoiIdx = -1;
68
+ for (let i = soiIdx + 2; i < this._buf.length - 1; i++) {
69
+ if (this._buf[i] === 0xff && this._buf[i + 1] === 0xd9) {
70
+ eoiIdx = i;
71
+ break;
72
+ }
73
+ }
74
+ if (eoiIdx === -1) {
75
+ // SOI found but no matching EOI yet – keep buffered from SOI onward.
76
+ this._buf = soiIdx > 0 ? this._buf.slice(soiIdx) : this._buf;
77
+ return;
78
+ }
79
+
80
+ // Emit the complete JPEG (inclusive of both markers).
81
+ this.emit('frame', this._buf.slice(soiIdx, eoiIdx + 2));
82
+ start = eoiIdx + 2;
83
+ }
84
+
85
+ // Retain any leftover bytes that haven't formed a complete frame yet.
86
+ this._buf = start < this._buf.length ? this._buf.slice(start) : null;
87
+ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // AndroidStream
92
+ //
93
+ // Streams the Android emulator screen by:
94
+ // 1. Running `adb exec-out screenrecord --output-format=h264 -` to get a
95
+ // continuous H.264 bitstream at up to 30 fps with no per-frame process
96
+ // overhead (replacing the previous one-shot screencap-per-frame approach).
97
+ // 2. Piping the bitstream through `ffmpeg` which decodes H.264 and re-encodes
98
+ // each frame as MJPEG at the requested fps / quality.
99
+ // 3. Parsing complete JPEG frames from ffmpeg stdout via JpegFrameParser and
100
+ // forwarding them to StreamHub.
101
+ // 4. Auto-restarting when Android's built-in 3-minute screenrecord limit
102
+ // expires (~170 s to give a small safety margin).
103
+ // 5. Falling back to screencap polling when H.264 streaming cannot start
104
+ // (e.g. older device without screenrecord stdout support).
105
+ // ---------------------------------------------------------------------------
11
106
  class AndroidStream {
12
- constructor({ userId, deviceId, controller, streamHub, fps = 10, quality = 75 }) {
107
+ constructor({ userId, deviceId, controller, streamHub, fps = 12, quality = 75 }) {
13
108
  this.userId = String(userId || '');
14
109
  this.deviceId = String(deviceId || '');
15
110
  this.controller = controller;
16
111
  this.streamHub = streamHub;
17
- this.fps = clampInt(fps, 10, 1, 15);
112
+ this.fps = clampInt(fps, 12, 1, 30);
18
113
  this.quality = clampInt(quality, 75, 30, 95);
19
- this._timer = null;
20
- this._capturing = false;
114
+
115
+ this._stopped = true;
116
+ this._adbProc = null;
117
+ this._ffmpegProc = null;
118
+ this._restartTimer = null;
119
+ this._h264StartupTimer = null;
21
120
  this._seq = 0;
121
+ this._lastErrorLogAt = 0;
122
+ this._usePollingFallback = false; // set true when H.264 fails to start
22
123
  }
23
124
 
24
125
  start() {
25
- if (this._timer) return;
26
- const interval = Math.max(1, Math.floor(1000 / this.fps));
27
- this._timer = setInterval(() => {
28
- void this._captureOnce();
29
- }, interval);
30
- this._timer.unref?.();
31
- void this._captureOnce();
126
+ if (!this._stopped) return;
127
+ this._stopped = false;
128
+ if (this._usePollingFallback) {
129
+ this._startPollingFallback();
130
+ } else {
131
+ this._launchH264();
132
+ }
32
133
  }
33
134
 
34
135
  stop() {
35
- if (this._timer) {
36
- clearInterval(this._timer);
37
- this._timer = null;
38
- }
136
+ this._stopped = true;
137
+ this._killProcesses();
39
138
  }
40
139
 
41
- async _captureOnce() {
42
- if (this._capturing) return;
43
- this._capturing = true;
140
+ // ── H.264 streaming (primary path) ─────────────────────────────────────
141
+
142
+ _launchH264() {
143
+ if (this._stopped) return;
144
+
145
+ const adb = resolveAdbBin(this.controller?.sdkDir);
146
+ // deviceId is the ADB serial (emulator-5554, etc.)
147
+ const serial = this.deviceId;
148
+ const adbArgs = [
149
+ '-s', serial,
150
+ 'exec-out',
151
+ 'screenrecord',
152
+ '--output-format=h264',
153
+ '--bit-rate=2000000',
154
+ '--size=1280x720',
155
+ '-',
156
+ ];
157
+
44
158
  try {
45
- if (!this.controller || typeof this.controller.capturePng !== 'function') {
46
- throw new Error('Android streaming requires a controller with capturePng().');
47
- }
48
- const png = await this.controller.capturePng({ deviceId: this.deviceId });
49
- if (!png?.length) return;
50
- const jpeg = await sharp(png).jpeg({ quality: this.quality }).toBuffer();
159
+ this._adbProc = spawn(adb, adbArgs, { stdio: ['ignore', 'pipe', 'ignore'] });
160
+ } catch (err) {
161
+ this._logError('Failed to spawn adb for H.264 streaming, falling back to screencap', err);
162
+ this._fallback();
163
+ return;
164
+ }
165
+
166
+ // Map our 30–95 quality range to ffmpeg q:v 2–20 (lower = better quality).
167
+ const ffmpegQ = Math.round(2 + ((95 - this.quality) / (95 - 30)) * 18);
168
+ const ffmpegArgs = [
169
+ '-loglevel', 'error',
170
+ '-f', 'h264',
171
+ '-i', 'pipe:0',
172
+ '-f', 'image2pipe',
173
+ '-vcodec', 'mjpeg',
174
+ '-q:v', String(ffmpegQ),
175
+ '-vf', `fps=${this.fps}`,
176
+ 'pipe:1',
177
+ ];
178
+
179
+ try {
180
+ this._ffmpegProc = spawn('ffmpeg', ffmpegArgs, { stdio: ['pipe', 'pipe', 'ignore'] });
181
+ } catch (err) {
182
+ this._logError('ffmpeg not found, falling back to screencap', err);
183
+ try { this._adbProc?.kill('SIGTERM'); } catch {}
184
+ this._adbProc = null;
185
+ this._fallback();
186
+ return;
187
+ }
188
+
189
+ // Wire adb stdout → ffmpeg stdin.
190
+ this._adbProc.stdout.pipe(this._ffmpegProc.stdin);
191
+
192
+ // Parse JPEG frames from ffmpeg stdout and forward to StreamHub.
193
+ const parser = new JpegFrameParser();
194
+ this._ffmpegProc.stdout.on('data', (chunk) => parser.push(chunk));
195
+ parser.on('frame', (jpeg) => {
196
+ if (this._stopped) return;
197
+ this._clearH264StartupTimer();
51
198
  this.streamHub.handleFrame(this.userId, this.deviceId, {
52
199
  jpeg,
53
200
  platform: 'android',
@@ -55,18 +202,135 @@ class AndroidStream {
55
202
  ts: Date.now() >>> 0,
56
203
  flags: 1,
57
204
  });
58
- } catch (error) {
59
- console.warn('[AndroidStream] frame capture failed', {
205
+ });
206
+
207
+ this._adbProc.on('error', (err) => {
208
+ this._logError('adb process error', err);
209
+ });
210
+ this._ffmpegProc.on('error', (err) => {
211
+ this._logError('ffmpeg process error', err);
212
+ });
213
+
214
+ if (H264_FIRST_FRAME_TIMEOUT_MS > 0) {
215
+ this._h264StartupTimer = setTimeout(() => {
216
+ if (this._stopped || this._usePollingFallback || this._seq > 0) return;
217
+ this._logError('H.264 stream produced no frames before timeout — using screencap fallback');
218
+ this._fallback();
219
+ }, H264_FIRST_FRAME_TIMEOUT_MS);
220
+ this._h264StartupTimer.unref?.();
221
+ }
222
+
223
+ // Android screenrecord's hard limit is 180 s — restart at 170 s so there
224
+ // is no gap in the stream.
225
+ this._restartTimer = setTimeout(() => {
226
+ if (!this._stopped) {
227
+ this._killProcesses();
228
+ this._launchH264();
229
+ }
230
+ }, 170_000);
231
+ this._restartTimer.unref?.();
232
+
233
+ // If adb exits early (e.g. emulator restart), attempt recovery.
234
+ this._adbProc.on('close', (code) => {
235
+ if (this._stopped || this._usePollingFallback) return;
236
+ // If it exited immediately with a bad code, the device likely does not
237
+ // support stdout screenrecord — fall back to screencap polling.
238
+ if (code !== 0 && this._seq === 0) {
239
+ this._logError(`screenrecord exited (code ${code}) before producing any frames — using screencap fallback`);
240
+ this._killProcesses();
241
+ this._fallback();
242
+ return;
243
+ }
244
+ this._logError(`adb screenrecord exited (code ${code}), restarting in 2 s`);
245
+ this._killProcesses();
246
+ this._scheduleRestart(2000, () => this._launchH264());
247
+ });
248
+
249
+ this._ffmpegProc.on('close', (code) => {
250
+ if (this._stopped || this._usePollingFallback) return;
251
+ if (this._seq === 0) {
252
+ this._logError(`ffmpeg exited (code ${code}) before producing any frames — using screencap fallback`);
253
+ this._fallback();
254
+ }
255
+ });
256
+ }
257
+
258
+ _fallback() {
259
+ this._killProcesses();
260
+ this._usePollingFallback = true;
261
+ if (!this._stopped) this._startPollingFallback();
262
+ }
263
+
264
+ // ── Screencap polling fallback ──────────────────────────────────────────
265
+ // Used when H.264 streaming is unavailable. Runs a tight continuous loop
266
+ // (no fixed interval) so there is never idle time waiting between captures.
267
+
268
+ _startPollingFallback() {
269
+ // Fire and forget — the loop runs until this._stopped is set.
270
+ void this._pollLoop();
271
+ }
272
+
273
+ async _pollLoop() {
274
+ while (!this._stopped) {
275
+ try {
276
+ if (!this.controller || typeof this.controller.capturePng !== 'function') {
277
+ throw new Error('Android streaming requires a controller with capturePng().');
278
+ }
279
+ const png = await this.controller.capturePng({ deviceId: this.deviceId });
280
+ if (this._stopped || !png?.length) continue;
281
+ const jpeg = await sharp(png)
282
+ .jpeg({ quality: this.quality, mozjpeg: false })
283
+ .toBuffer();
284
+ if (this._stopped) return;
285
+ this.streamHub.handleFrame(this.userId, this.deviceId, {
286
+ jpeg,
287
+ platform: 'android',
288
+ seq: this._seq++ >>> 0,
289
+ ts: Date.now() >>> 0,
290
+ flags: 1,
291
+ });
292
+ } catch (error) {
293
+ this._logError('screencap poll failed', error);
294
+ // Brief pause before retrying to avoid a tight spin on persistent errors.
295
+ await new Promise((r) => setTimeout(r, 500));
296
+ }
297
+ }
298
+ }
299
+
300
+ // ── Helpers ─────────────────────────────────────────────────────────────
301
+
302
+ _scheduleRestart(delayMs, fn) {
303
+ this._restartTimer = setTimeout(fn, delayMs);
304
+ this._restartTimer.unref?.();
305
+ }
306
+
307
+ _killProcesses() {
308
+ clearTimeout(this._restartTimer);
309
+ this._restartTimer = null;
310
+ this._clearH264StartupTimer();
311
+ // Kill ffmpeg first so it stops reading; then kill adb.
312
+ try { this._ffmpegProc?.kill('SIGTERM'); } catch {}
313
+ try { this._adbProc?.kill('SIGTERM'); } catch {}
314
+ this._ffmpegProc = null;
315
+ this._adbProc = null;
316
+ }
317
+
318
+ _clearH264StartupTimer() {
319
+ clearTimeout(this._h264StartupTimer);
320
+ this._h264StartupTimer = null;
321
+ }
322
+
323
+ _logError(msg, err) {
324
+ const now = Date.now();
325
+ if (now - this._lastErrorLogAt > 10_000) {
326
+ this._lastErrorLogAt = now;
327
+ console.warn(`[AndroidStream] ${msg}`, {
60
328
  userId: this.userId,
61
329
  deviceId: this.deviceId,
62
- error: String(error?.message || error),
330
+ error: err ? String(err?.message || err) : undefined,
63
331
  });
64
- } finally {
65
- this._capturing = false;
66
332
  }
67
333
  }
68
334
  }
69
335
 
70
- module.exports = {
71
- AndroidStream,
72
- };
336
+ module.exports = { AndroidStream };
@@ -74,7 +74,7 @@ class StreamHub {
74
74
  capturedAt: now,
75
75
  };
76
76
  for (const socketId of subscribers) {
77
- this._io.to(socketId).emit('stream:frame', meta, frame.jpeg);
77
+ this._io.to(socketId).volatile.emit('stream:frame', meta, frame.jpeg);
78
78
  }
79
79
  }
80
80
 
@@ -275,7 +275,10 @@ async function pollIntegrationTask(runtime, task) {
275
275
  const startIndex = rows.findIndex((row) => row.fingerprint === existingFingerprint);
276
276
  const pending = startIndex >= 0 ? rows.slice(startIndex + 1) : rows.slice(-1);
277
277
  for (const row of pending) {
278
- await runtime.fireTaskFromTrigger(task.id, task.user_id, row);
278
+ const result = await runtime.fireTaskFromTrigger(task.id, task.user_id, row);
279
+ if (result?.error || (result?.skipped && result.reason !== 'duplicate_trigger')) {
280
+ break;
281
+ }
279
282
  }
280
283
  }
281
284