neoagent 2.4.0 → 2.4.1-beta.10

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 (57) hide show
  1. package/LICENSE +619 -21
  2. package/README.md +1 -1
  3. package/extensions/chrome-browser/background.mjs +19 -7
  4. package/extensions/chrome-browser/icons/icon128.png +0 -0
  5. package/extensions/chrome-browser/icons/icon16.png +0 -0
  6. package/extensions/chrome-browser/icons/icon48.png +0 -0
  7. package/extensions/chrome-browser/icons/logo.svg +12 -0
  8. package/extensions/chrome-browser/manifest.json +13 -2
  9. package/extensions/chrome-browser/popup.css +5 -0
  10. package/extensions/chrome-browser/popup.html +7 -5
  11. package/extensions/chrome-browser/popup.js +16 -7
  12. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +391 -0
  13. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +6 -0
  14. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +1 -1
  15. package/flutter_app/lib/main.dart +1 -0
  16. package/flutter_app/lib/main_controller.dart +156 -3
  17. package/flutter_app/lib/main_devices.dart +485 -119
  18. package/flutter_app/lib/main_settings.dart +289 -30
  19. package/flutter_app/lib/src/backend_client.dart +89 -0
  20. package/flutter_app/lib/src/desktop_companion_actions.dart +144 -0
  21. package/flutter_app/lib/src/desktop_companion_io.dart +145 -4
  22. package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
  23. package/flutter_app/lib/src/stream_renderer.dart +286 -0
  24. package/flutter_app/macos/Runner/AppDelegate.swift +56 -1
  25. package/package.json +2 -2
  26. package/server/guest_agent.js +19 -1
  27. package/server/http/routes.js +191 -0
  28. package/server/http/socket.js +1 -1
  29. package/server/index.js +4 -1
  30. package/server/public/.last_build_id +1 -1
  31. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  32. package/server/public/flutter_bootstrap.js +1 -1
  33. package/server/public/main.dart.js +73834 -72596
  34. package/server/routes/browser.js +14 -0
  35. package/server/routes/browser_extension.js +21 -4
  36. package/server/routes/desktop.js +10 -0
  37. package/server/routes/settings.js +4 -0
  38. package/server/routes/stream.js +187 -0
  39. package/server/services/ai/tools.js +40 -29
  40. package/server/services/android/controller.js +41 -2
  41. package/server/services/browser/controller.js +34 -0
  42. package/server/services/browser/extension/manifest.js +33 -0
  43. package/server/services/browser/extension/provider.js +12 -6
  44. package/server/services/browser/extension/registry.js +188 -18
  45. package/server/services/desktop/gateway.js +28 -3
  46. package/server/services/desktop/protocol.js +34 -0
  47. package/server/services/desktop/provider.js +25 -0
  48. package/server/services/desktop/registry.js +92 -10
  49. package/server/services/manager.js +19 -2
  50. package/server/services/runtime/backends/local-vm.js +6 -0
  51. package/server/services/runtime/docker-vm-manager.js +26 -3
  52. package/server/services/runtime/manager.js +36 -5
  53. package/server/services/runtime/settings.js +17 -0
  54. package/server/services/streaming/android-stream.js +298 -0
  55. package/server/services/streaming/browser-stream.js +87 -0
  56. package/server/services/streaming/stream-hub.js +231 -0
  57. package/server/services/websocket.js +73 -0
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ const MAX_STREAM_FRAME_BYTES = 8 * 1024 * 1024;
4
+ const NO_SUBSCRIBER_GRACE_MS = Number(process.env.NEOAGENT_STREAM_NO_SUBSCRIBER_GRACE_MS || 15_000);
5
+
6
+ function normalizePlatform(platform) {
7
+ const value = String(platform || '').trim().toLowerCase();
8
+ return value || 'desktop';
9
+ }
10
+
11
+ function streamKey(userId, platform, deviceId) {
12
+ return [
13
+ String(userId || '').trim(),
14
+ normalizePlatform(platform),
15
+ String(deviceId || '').trim(),
16
+ ].join(':');
17
+ }
18
+
19
+ class StreamHub {
20
+ constructor(io) {
21
+ this._io = io;
22
+ this._subscribers = new Map();
23
+ this._stats = new Map();
24
+ this._activeStreams = new Map();
25
+ }
26
+
27
+ handleFrame(userId, deviceId, frame = {}) {
28
+ const platform = normalizePlatform(frame.platform);
29
+ const key = streamKey(userId, platform, deviceId);
30
+ const subscribers = this._subscribers.get(key);
31
+ if (
32
+ !subscribers?.size
33
+ || !Buffer.isBuffer(frame.jpeg)
34
+ || frame.jpeg.length === 0
35
+ || frame.jpeg.length > MAX_STREAM_FRAME_BYTES
36
+ ) {
37
+ return;
38
+ }
39
+
40
+ const now = Date.now();
41
+ const stats = this._stats.get(key) || {
42
+ frameCount: 0,
43
+ bytesTotal: 0,
44
+ lastFrameAt: 0,
45
+ startedAt: now,
46
+ windowFrameCount: 0,
47
+ windowBytesTotal: 0,
48
+ windowStartedAt: now,
49
+ actualFps: 0,
50
+ bytesPerSec: 0,
51
+ };
52
+ stats.frameCount += 1;
53
+ stats.bytesTotal += frame.jpeg.length;
54
+ stats.lastFrameAt = now;
55
+ stats.windowFrameCount += 1;
56
+ stats.windowBytesTotal += frame.jpeg.length;
57
+ const windowMs = Math.max(1, now - stats.windowStartedAt);
58
+ if (windowMs >= 1000) {
59
+ stats.actualFps = Math.round((stats.windowFrameCount * 1000 / windowMs) * 10) / 10;
60
+ stats.bytesPerSec = Math.round(stats.windowBytesTotal * 1000 / windowMs);
61
+ stats.windowStartedAt = now;
62
+ stats.windowFrameCount = 0;
63
+ stats.windowBytesTotal = 0;
64
+ }
65
+ this._stats.set(key, stats);
66
+
67
+ const active = this._activeStreams.get(key);
68
+ const meta = {
69
+ deviceId: String(deviceId || ''),
70
+ platform: active?.platform || platform,
71
+ seq: frame.seq ?? null,
72
+ ts: frame.ts ?? null,
73
+ flags: frame.flags ?? 0,
74
+ capturedAt: now,
75
+ };
76
+ for (const socketId of subscribers) {
77
+ this._io.to(socketId).volatile.emit('stream:frame', meta, frame.jpeg);
78
+ }
79
+ }
80
+
81
+ subscribe(userId, deviceId, platform, socketId) {
82
+ const key = streamKey(userId, platform, deviceId);
83
+ if (!this._subscribers.has(key)) this._subscribers.set(key, new Set());
84
+ this._subscribers.get(key).add(socketId);
85
+ const active = this._activeStreams.get(key);
86
+ if (active && platform) {
87
+ active.platform = normalizePlatform(platform);
88
+ if (active.noSubscriberTimer) {
89
+ clearTimeout(active.noSubscriberTimer);
90
+ active.noSubscriberTimer = null;
91
+ }
92
+ }
93
+ return this.subscriberCount(userId, platform, deviceId);
94
+ }
95
+
96
+ async unsubscribe(userId, platform, deviceId, socketId) {
97
+ const key = streamKey(userId, platform, deviceId);
98
+ const subscribers = this._subscribers.get(key);
99
+ if (!subscribers) return 0;
100
+ subscribers.delete(socketId);
101
+ const count = subscribers.size;
102
+ if (count === 0) {
103
+ this._subscribers.delete(key);
104
+ await this.stopStream(userId, platform, deviceId, 'no_subscribers');
105
+ }
106
+ return count;
107
+ }
108
+
109
+ async unsubscribeAll(socketId) {
110
+ const emptyKeys = [];
111
+ for (const [key, subscribers] of this._subscribers.entries()) {
112
+ subscribers.delete(socketId);
113
+ if (subscribers.size === 0) emptyKeys.push(key);
114
+ }
115
+ for (const key of emptyKeys) {
116
+ this._subscribers.delete(key);
117
+ const active = this._activeStreams.get(key);
118
+ if (active) {
119
+ await this.stopStream(active.userId, active.platform, active.deviceId, 'socket_disconnected');
120
+ }
121
+ }
122
+ }
123
+
124
+ subscriberCount(userId, platform, deviceId) {
125
+ return this._subscribers.get(streamKey(userId, platform, deviceId))?.size || 0;
126
+ }
127
+
128
+ markStarted(userId, deviceId, platform, options = {}, stop) {
129
+ const normalizedPlatform = normalizePlatform(platform);
130
+ const key = streamKey(userId, normalizedPlatform, deviceId);
131
+ const active = {
132
+ userId: String(userId || ''),
133
+ deviceId: String(deviceId || ''),
134
+ platform: normalizedPlatform,
135
+ fps: Number(options.fps) || null,
136
+ quality: Number(options.quality) || null,
137
+ startedAt: Date.now(),
138
+ noSubscriberTimer: null,
139
+ stop: typeof stop === 'function' ? stop : null,
140
+ };
141
+ this._activeStreams.set(key, active);
142
+ if (NO_SUBSCRIBER_GRACE_MS > 0 && this.subscriberCount(userId, normalizedPlatform, deviceId) === 0) {
143
+ active.noSubscriberTimer = setTimeout(() => {
144
+ void this.stopStream(userId, normalizedPlatform, deviceId, 'no_subscribers_initial');
145
+ }, NO_SUBSCRIBER_GRACE_MS);
146
+ active.noSubscriberTimer.unref?.();
147
+ }
148
+ if (!this._stats.has(key)) {
149
+ this._stats.set(key, {
150
+ frameCount: 0,
151
+ bytesTotal: 0,
152
+ lastFrameAt: 0,
153
+ startedAt: Date.now(),
154
+ windowFrameCount: 0,
155
+ windowBytesTotal: 0,
156
+ windowStartedAt: Date.now(),
157
+ actualFps: 0,
158
+ bytesPerSec: 0,
159
+ });
160
+ }
161
+ }
162
+
163
+ async stopStream(userId, platform, deviceId, reason = 'stopped') {
164
+ const key = streamKey(userId, platform, deviceId);
165
+ const active = this._activeStreams.get(key);
166
+ if (!active) return false;
167
+ this._activeStreams.delete(key);
168
+ if (active.noSubscriberTimer) {
169
+ clearTimeout(active.noSubscriberTimer);
170
+ active.noSubscriberTimer = null;
171
+ }
172
+ if (active.stop) {
173
+ await Promise.resolve(active.stop(reason)).catch((error) => {
174
+ console.warn('[StreamHub] stream stop failed', {
175
+ userId: active.userId,
176
+ deviceId: active.deviceId,
177
+ platform: active.platform,
178
+ reason,
179
+ error: String(error?.message || error),
180
+ });
181
+ });
182
+ }
183
+ return true;
184
+ }
185
+
186
+ status(userId, platform, deviceId) {
187
+ const key = streamKey(userId, platform, deviceId);
188
+ const active = this._activeStreams.get(key) || null;
189
+ const stats = this._stats.get(key) || {};
190
+ const normalizedPlatform = normalizePlatform(platform);
191
+ return {
192
+ streaming: Boolean(active),
193
+ platform: active?.platform || normalizedPlatform,
194
+ deviceId: active?.deviceId || String(deviceId || ''),
195
+ fps: active?.fps || null,
196
+ quality: active?.quality || null,
197
+ subscriberCount: this.subscriberCount(userId, normalizedPlatform, deviceId),
198
+ frameCount: stats.frameCount || 0,
199
+ bytesTotal: stats.bytesTotal || 0,
200
+ lastFrameAt: stats.lastFrameAt || null,
201
+ actualFps: stats.actualFps || 0,
202
+ bytesPerSec: stats.bytesPerSec || 0,
203
+ };
204
+ }
205
+
206
+ listStatus(userId) {
207
+ return Array.from(this._activeStreams.values())
208
+ .filter((stream) => stream.userId === String(userId || '').trim())
209
+ .map((stream) => this.status(userId, stream.platform, stream.deviceId))
210
+ .filter((status) => status.deviceId || status.streaming || status.subscriberCount > 0);
211
+ }
212
+
213
+ async shutdown() {
214
+ const streams = Array.from(this._activeStreams.values());
215
+ this._activeStreams.clear();
216
+ this._subscribers.clear();
217
+ await Promise.allSettled(streams.map((stream) => {
218
+ if (stream.noSubscriberTimer) clearTimeout(stream.noSubscriberTimer);
219
+ if (!stream.stop) return null;
220
+ return Promise.resolve(stream.stop('shutdown'));
221
+ }));
222
+ }
223
+ }
224
+
225
+ module.exports = {
226
+ StreamHub,
227
+ MAX_STREAM_FRAME_BYTES,
228
+ NO_SUBSCRIBER_GRACE_MS,
229
+ normalizePlatform,
230
+ streamKey,
231
+ };
@@ -35,6 +35,8 @@ const EVENT_RATE_LIMITS = Object.freeze({
35
35
  'integrations:status': { windowMs: 10 * 1000, max: 20 },
36
36
  'memory:read': { windowMs: 10 * 1000, max: 20 },
37
37
  'memory:search': { windowMs: 10 * 1000, max: 20 },
38
+ 'stream:subscribe': { windowMs: 10 * 1000, max: 40 },
39
+ 'stream:unsubscribe': { windowMs: 10 * 1000, max: 40 },
38
40
  });
39
41
 
40
42
  function asObject(value) {
@@ -54,6 +56,19 @@ function toBoundedInt(value, fallback, min, max) {
54
56
  return Math.min(max, Math.max(min, Math.floor(parsed)));
55
57
  }
56
58
 
59
+ function normalizeStreamPayload(raw) {
60
+ const data = asObject(raw);
61
+ const platform = toOptionalString(data?.platform, 32).toLowerCase() || 'desktop';
62
+ if (!['desktop', 'android', 'browser'].includes(platform)) {
63
+ throw new Error('platform must be desktop, android, or browser.');
64
+ }
65
+ const deviceId = toOptionalString(data?.deviceId || data?.device_id, 256);
66
+ return {
67
+ platform,
68
+ deviceId: deviceId || (platform === 'browser' ? 'browser' : ''),
69
+ };
70
+ }
71
+
57
72
  function resolveAgentFromPayload(userId, value) {
58
73
  const data = asObject(value);
59
74
  return resolveAgentId(userId, data?.agentId || data?.agent_id || null);
@@ -831,9 +846,67 @@ function setupWebSocket(io, services) {
831
846
  }
832
847
  });
833
848
 
849
+ // ── Remote Control Streams ──
850
+
851
+ socket.on('stream:subscribe', (raw) => {
852
+ const limit = allowEvent('stream:subscribe');
853
+ if (!limit.allowed) {
854
+ recordRateLimitHit(rateLimitObserver, userId, socket.id, 'stream:subscribe', limit.retryAfterMs);
855
+ return socket.emit('stream:error', {
856
+ error: `Rate limit exceeded for stream:subscribe. Retry in ${Math.ceil(limit.retryAfterMs / 1000)}s.`,
857
+ });
858
+ }
859
+ try {
860
+ const streamHub = services.streamHub || services.app?.locals?.streamHub;
861
+ if (!streamHub) throw new Error('Stream hub is unavailable.');
862
+ const data = normalizeStreamPayload(raw);
863
+ if (!data.deviceId) throw new Error('deviceId is required.');
864
+ const subscriberCount = streamHub.subscribe(userId, data.deviceId, data.platform, socket.id);
865
+ socket.emit('stream:subscribed', {
866
+ platform: data.platform,
867
+ deviceId: data.deviceId,
868
+ subscriberCount,
869
+ });
870
+ } catch (err) {
871
+ console.error(`[WS] stream:subscribe failed for user ${userId}:`, err);
872
+ socket.emit('stream:error', { error: sanitizeError(err) });
873
+ }
874
+ });
875
+
876
+ socket.on('stream:unsubscribe', async (raw) => {
877
+ const limit = allowEvent('stream:unsubscribe');
878
+ if (!limit.allowed) {
879
+ recordRateLimitHit(rateLimitObserver, userId, socket.id, 'stream:unsubscribe', limit.retryAfterMs);
880
+ return socket.emit('stream:error', {
881
+ error: `Rate limit exceeded for stream:unsubscribe. Retry in ${Math.ceil(limit.retryAfterMs / 1000)}s.`,
882
+ });
883
+ }
884
+ try {
885
+ const streamHub = services.streamHub || services.app?.locals?.streamHub;
886
+ if (!streamHub) throw new Error('Stream hub is unavailable.');
887
+ const data = normalizeStreamPayload(raw);
888
+ if (!data.deviceId) throw new Error('deviceId is required.');
889
+ const subscriberCount = await streamHub.unsubscribe(userId, data.platform, data.deviceId, socket.id);
890
+ socket.emit('stream:unsubscribed', {
891
+ platform: data.platform,
892
+ deviceId: data.deviceId,
893
+ subscriberCount,
894
+ });
895
+ } catch (err) {
896
+ console.error(`[WS] stream:unsubscribe failed for user ${userId}:`, err);
897
+ socket.emit('stream:error', { error: sanitizeError(err) });
898
+ }
899
+ });
900
+
834
901
  // ── Disconnect ──
835
902
 
836
903
  socket.on('disconnect', () => {
904
+ const streamHub = services.streamHub || services.app?.locals?.streamHub;
905
+ if (streamHub && typeof streamHub.unsubscribeAll === 'function') {
906
+ void streamHub.unsubscribeAll(socket.id).catch((err) => {
907
+ console.error(`[WS] Failed to unsubscribe streams for socket ${socket.id}:`, err);
908
+ });
909
+ }
837
910
  if (!voiceRuntimeManager || typeof voiceRuntimeManager.closeSession !== 'function') {
838
911
  socket.data.voiceSessionIds?.clear?.();
839
912
  console.log(`[WS] User ${userId} disconnected (${socket.id})`);