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
@@ -8,6 +8,9 @@ const {
8
8
 
9
9
  const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000;
10
10
  const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 1000;
11
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 25 * 1000;
12
+ const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75 * 1000;
13
+ const DEFAULT_PRESENCE_TOUCH_INTERVAL_MS = 15 * 1000;
11
14
 
12
15
  function sha256(value) {
13
16
  return crypto.createHash('sha256').update(String(value || '')).digest('hex');
@@ -42,9 +45,21 @@ class BrowserExtensionRegistry {
42
45
  this.db = options.db || db;
43
46
  this.commandTimeoutMs = Number(options.commandTimeoutMs || process.env.NEOAGENT_BROWSER_EXTENSION_COMMAND_TIMEOUT_MS || DEFAULT_COMMAND_TIMEOUT_MS);
44
47
  this.pairingTtlMs = Number(options.pairingTtlMs || process.env.NEOAGENT_BROWSER_EXTENSION_PAIRING_TTL_MS || DEFAULT_PAIRING_TTL_MS);
48
+ this.heartbeatIntervalMs = Number(options.heartbeatIntervalMs || process.env.NEOAGENT_BROWSER_EXTENSION_HEARTBEAT_INTERVAL_MS || DEFAULT_HEARTBEAT_INTERVAL_MS);
49
+ this.heartbeatTimeoutMs = Number(options.heartbeatTimeoutMs || process.env.NEOAGENT_BROWSER_EXTENSION_HEARTBEAT_TIMEOUT_MS || DEFAULT_HEARTBEAT_TIMEOUT_MS);
50
+ this.presenceTouchIntervalMs = Number(options.presenceTouchIntervalMs || process.env.NEOAGENT_BROWSER_EXTENSION_PRESENCE_TOUCH_INTERVAL_MS || DEFAULT_PRESENCE_TOUCH_INTERVAL_MS);
45
51
  this.connectionsByUser = new Map();
46
52
  }
47
53
 
54
+ #getUserConnections(userId, create = false) {
55
+ const key = String(userId || '').trim();
56
+ if (!key) return null;
57
+ if (!this.connectionsByUser.has(key) && create) {
58
+ this.connectionsByUser.set(key, new Map());
59
+ }
60
+ return this.connectionsByUser.get(key) || null;
61
+ }
62
+
48
63
  createPairingRequest(options = {}) {
49
64
  const pairingId = crypto.randomUUID();
50
65
  const pairingSecret = randomSecret(48);
@@ -129,16 +144,42 @@ class BrowserExtensionRegistry {
129
144
  };
130
145
 
131
146
  const tx = this.db.transaction(() => {
147
+ // Find other active tokens with the same name for this user.
148
+ // If they are currently offline, revoke them to clean up old installs/duplicates.
149
+ const duplicates = this.db.prepare(
150
+ `SELECT id FROM browser_extension_tokens
151
+ WHERE user_id = ? AND name = ? AND status = 'active'`
152
+ ).all(row.user_id, extensionName);
153
+
154
+ for (const dup of duplicates) {
155
+ if (!this.isConnected(row.user_id, dup.id)) {
156
+ this.db.prepare(
157
+ `UPDATE browser_extension_tokens
158
+ SET status = 'revoked', revoked_at = datetime('now')
159
+ WHERE id = ?`
160
+ ).run(dup.id);
161
+ }
162
+ }
163
+
132
164
  this.db.prepare(
133
165
  `UPDATE browser_extension_pairing_requests
134
166
  SET status = 'claimed', claimed_at = datetime('now')
135
167
  WHERE id = ?`
136
168
  ).run(row.id);
169
+
137
170
  this.db.prepare(
138
171
  `INSERT INTO browser_extension_tokens (
139
172
  id, user_id, token_hash, name, status, metadata_json
140
173
  ) VALUES (?, ?, ?, ?, 'active', ?)`
141
174
  ).run(tokenId, row.user_id, sha256(token), extensionName, safeJson(metadata));
175
+
176
+ // Auto-select the newly paired token on claim
177
+ const write = this.db.prepare(
178
+ `INSERT INTO user_settings (user_id, key, value) VALUES (?, ?, ?)
179
+ ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value`
180
+ );
181
+ write.run(row.user_id, 'browser_extension_token_id', tokenId);
182
+ write.run(row.user_id, 'selected_browser_extension_token_id', tokenId);
142
183
  });
143
184
  tx();
144
185
 
@@ -164,7 +205,8 @@ class BrowserExtensionRegistry {
164
205
 
165
206
  registerConnection(tokenRow, ws, meta = {}) {
166
207
  const userId = String(tokenRow.user_id);
167
- const existing = this.connectionsByUser.get(userId);
208
+ const userMap = this.#getUserConnections(userId, true);
209
+ const existing = userMap.get(tokenRow.id);
168
210
  if (existing && existing.ws !== ws) {
169
211
  existing.close('replaced by a newer extension connection');
170
212
  }
@@ -176,8 +218,11 @@ class BrowserExtensionRegistry {
176
218
  tokenId: tokenRow.id,
177
219
  meta: { ...tokenRow.metadata, ...meta },
178
220
  timeoutMs: this.commandTimeoutMs,
221
+ heartbeatIntervalMs: this.heartbeatIntervalMs,
222
+ heartbeatTimeoutMs: this.heartbeatTimeoutMs,
223
+ presenceTouchIntervalMs: this.presenceTouchIntervalMs,
179
224
  });
180
- this.connectionsByUser.set(userId, connection);
225
+ userMap.set(tokenRow.id, connection);
181
226
  this.db.prepare(
182
227
  `UPDATE browser_extension_tokens
183
228
  SET last_connected_at = datetime('now'), last_seen_at = datetime('now')
@@ -188,21 +233,102 @@ class BrowserExtensionRegistry {
188
233
 
189
234
  unregisterConnection(connection) {
190
235
  const userId = String(connection.userId);
191
- if (this.connectionsByUser.get(userId) === connection) {
192
- this.connectionsByUser.delete(userId);
236
+ const userMap = this.#getUserConnections(userId);
237
+ if (userMap?.get(connection.tokenId) === connection) {
238
+ userMap.delete(connection.tokenId);
239
+ if (userMap.size === 0) {
240
+ this.connectionsByUser.delete(userId);
241
+ }
242
+ }
243
+ }
244
+
245
+ getSelectedTokenId(userId) {
246
+ const value = this.db.prepare(
247
+ `SELECT value FROM user_settings WHERE user_id = ? AND key = ?`
248
+ ).get(userId, 'browser_extension_token_id')?.value || null;
249
+ const normalized = String(value || '').trim();
250
+ if (!normalized || normalized === 'null') return null;
251
+ const existing = this.db.prepare(
252
+ `SELECT id FROM browser_extension_tokens WHERE user_id = ? AND id = ? AND status = 'active'`
253
+ ).get(userId, normalized);
254
+ if (!existing) return null;
255
+ return normalized;
256
+ }
257
+
258
+ setSelectedTokenId(userId, tokenId) {
259
+ const normalized = String(tokenId || '').trim();
260
+ if (!normalized) {
261
+ const error = new Error('Browser extension token id is required.');
262
+ error.status = 400;
263
+ throw error;
264
+ }
265
+ const existing = this.db.prepare(
266
+ `SELECT id FROM browser_extension_tokens WHERE user_id = ? AND id = ? AND status = 'active'`
267
+ ).get(userId, normalized);
268
+ if (!existing) {
269
+ const error = new Error('Browser extension device not found.');
270
+ error.status = 404;
271
+ throw error;
272
+ }
273
+ const write = this.db.prepare(
274
+ `INSERT INTO user_settings (user_id, key, value) VALUES (?, ?, ?)
275
+ ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value`
276
+ );
277
+ write.run(userId, 'browser_extension_token_id', normalized);
278
+ write.run(userId, 'selected_browser_extension_token_id', normalized);
279
+ return { success: true, selectedTokenId: normalized };
280
+ }
281
+
282
+ getConnection(userId, tokenId = null) {
283
+ const userMap = this.#getUserConnections(userId);
284
+ if (!userMap) return null;
285
+ const explicit = String(tokenId || '').trim();
286
+ if (explicit) return userMap.get(explicit) || null;
287
+
288
+ const selected = this.getSelectedTokenId(userId);
289
+ if (selected && userMap.get(selected)?.isOpen()) {
290
+ return userMap.get(selected);
291
+ }
292
+
293
+ // Auto-select online connection if selected is offline/unset
294
+ const online = Array.from(userMap.values()).filter((connection) => connection.isOpen());
295
+ if (online.length > 0) {
296
+ online.sort((a, b) => String(b.connectedAt || '').localeCompare(String(a.connectedAt || '')));
297
+ const activeConnection = online[0];
298
+ try {
299
+ const write = this.db.prepare(
300
+ `INSERT INTO user_settings (user_id, key, value) VALUES (?, ?, ?)
301
+ ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value`
302
+ );
303
+ write.run(userId, 'browser_extension_token_id', activeConnection.tokenId);
304
+ write.run(userId, 'selected_browser_extension_token_id', activeConnection.tokenId);
305
+ } catch (err) {
306
+ console.error('[Registry] failed to auto-select online extension', err);
307
+ }
308
+ return activeConnection;
193
309
  }
310
+
311
+ if (selected) {
312
+ return userMap.get(selected) || null;
313
+ }
314
+ return null;
194
315
  }
195
316
 
196
- getConnection(userId) {
197
- return this.connectionsByUser.get(String(userId));
317
+ isConnected(userId, tokenId = null) {
318
+ return Boolean(this.getConnection(userId, tokenId)?.isOpen());
198
319
  }
199
320
 
200
- isConnected(userId) {
201
- return Boolean(this.getConnection(userId)?.isOpen());
321
+ touchPresence(userId, tokenId) {
322
+ const userMap = this.#getUserConnections(userId);
323
+ const connection = userMap?.get(String(tokenId));
324
+ if (!connection?.isOpen()) return;
325
+ this.db.prepare(
326
+ `UPDATE browser_extension_tokens SET last_seen_at = datetime('now') WHERE id = ?`
327
+ ).run(tokenId);
202
328
  }
203
329
 
204
330
  async dispatch(userId, command, payload = {}, options = {}) {
205
- const connection = this.getConnection(userId);
331
+ const connection = this.getConnection(userId, options.tokenId || payload.tokenId || null);
206
332
  if (!connection || !connection.isOpen()) {
207
333
  throw new ExtensionBrowserUnavailableError();
208
334
  }
@@ -214,20 +340,41 @@ class BrowserExtensionRegistry {
214
340
  }
215
341
 
216
342
  getStatus(userId) {
217
- const connected = this.getConnection(userId);
343
+ const userMap = this.#getUserConnections(userId);
344
+ let selectedTokenId = this.getSelectedTokenId(userId);
345
+ let connected = selectedTokenId
346
+ ? userMap?.get(selectedTokenId)
347
+ : null;
348
+ if (!connected?.isOpen()) {
349
+ connected = this.getConnection(userId);
350
+ if (connected?.isOpen()) {
351
+ selectedTokenId = connected.tokenId;
352
+ }
353
+ }
354
+ const effectiveSelectedTokenId = selectedTokenId || connected?.tokenId || null;
218
355
  const tokens = this.db.prepare(
219
356
  `SELECT id, name, status, last_connected_at, last_seen_at, revoked_at, created_at, metadata_json
220
357
  FROM browser_extension_tokens
221
358
  WHERE user_id = ?
222
359
  ORDER BY created_at DESC`
223
- ).all(userId).map((row) => ({
224
- ...row,
225
- metadata: parseJson(row.metadata_json),
226
- metadata_json: undefined,
227
- }));
360
+ ).all(userId).map((row) => {
361
+ const connection = userMap?.get(row.id) || null;
362
+ return {
363
+ ...row,
364
+ tokenId: row.id,
365
+ deviceId: row.id,
366
+ connected: Boolean(connection?.isOpen()),
367
+ online: Boolean(connection?.isOpen()),
368
+ selected: row.id === effectiveSelectedTokenId,
369
+ metadata: parseJson(row.metadata_json),
370
+ connectedMeta: connection?.meta || null,
371
+ metadata_json: undefined,
372
+ };
373
+ });
228
374
  return {
229
375
  connected: Boolean(connected?.isOpen()),
230
376
  activeTokenId: connected?.tokenId || null,
377
+ selectedTokenId: effectiveSelectedTokenId,
231
378
  tokens,
232
379
  connectedMeta: connected?.meta || null,
233
380
  };
@@ -249,7 +396,7 @@ class BrowserExtensionRegistry {
249
396
  ).run(userId);
250
397
  }
251
398
 
252
- const connection = this.getConnection(userId);
399
+ const connection = this.getConnection(userId, targetTokenId);
253
400
  if (connection && (!targetTokenId || connection.tokenId === targetTokenId)) {
254
401
  connection.close('extension token revoked');
255
402
  }
@@ -258,25 +405,43 @@ class BrowserExtensionRegistry {
258
405
 
259
406
  closeAll() {
260
407
  for (const connection of this.connectionsByUser.values()) {
261
- connection.close('server shutdown');
408
+ if (connection instanceof Map) {
409
+ for (const nested of connection.values()) {
410
+ nested.close('server shutdown');
411
+ }
412
+ } else {
413
+ connection.close('server shutdown');
414
+ }
262
415
  }
263
416
  this.connectionsByUser.clear();
264
417
  }
265
418
  }
266
419
 
267
420
  class ExtensionBrowserConnection {
268
- constructor({ registry, ws, userId, tokenId, meta, timeoutMs }) {
421
+ constructor({ registry, ws, userId, tokenId, meta, timeoutMs, heartbeatIntervalMs, heartbeatTimeoutMs, presenceTouchIntervalMs }) {
269
422
  this.registry = registry;
270
423
  this.ws = ws;
271
424
  this.userId = userId;
272
425
  this.tokenId = tokenId;
273
426
  this.meta = meta || {};
274
427
  this.timeoutMs = timeoutMs;
428
+ this.heartbeatIntervalMs = heartbeatIntervalMs;
429
+ this.heartbeatTimeoutMs = heartbeatTimeoutMs;
430
+ this.presenceTouchIntervalMs = presenceTouchIntervalMs;
275
431
  this.pending = new Map();
432
+ this.connectedAt = new Date().toISOString();
433
+ this.lastPongAt = Date.now();
434
+ this.lastPresenceTouchAt = 0;
435
+ this.heartbeatTimer = null;
276
436
 
277
437
  ws.on('message', (data) => this.#handleMessage(data));
438
+ ws.on('pong', () => {
439
+ this.lastPongAt = Date.now();
440
+ this.touchPresence();
441
+ });
278
442
  ws.on('close', () => this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.')));
279
443
  ws.on('error', (error) => this.#closePending(error));
444
+ this.#startHeartbeat();
280
445
  }
281
446
 
282
447
  isOpen() {
@@ -293,6 +458,21 @@ class ExtensionBrowserConnection {
293
458
  this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.'));
294
459
  }
295
460
 
461
+ touchPresence({ force = false } = {}) {
462
+ const now = Date.now();
463
+ const intervalMs = Number(this.presenceTouchIntervalMs);
464
+ if (
465
+ !force
466
+ && Number.isFinite(intervalMs)
467
+ && intervalMs > 0
468
+ && now - this.lastPresenceTouchAt < intervalMs
469
+ ) {
470
+ return;
471
+ }
472
+ this.lastPresenceTouchAt = now;
473
+ this.registry.touchPresence(this.userId, this.tokenId);
474
+ }
475
+
296
476
  sendCommand(command, payload = {}, options = {}) {
297
477
  if (!this.isOpen()) {
298
478
  return Promise.reject(new ExtensionBrowserUnavailableError());
@@ -317,6 +497,8 @@ class ExtensionBrowserConnection {
317
497
  }
318
498
 
319
499
  #handleMessage(data) {
500
+ this.lastPongAt = Date.now();
501
+ this.touchPresence();
320
502
  let message;
321
503
  try {
322
504
  message = parseExtensionMessage(data);
@@ -339,12 +521,41 @@ class ExtensionBrowserConnection {
339
521
 
340
522
  #closePending(error) {
341
523
  this.registry.unregisterConnection(this);
524
+ if (this.heartbeatTimer) {
525
+ clearInterval(this.heartbeatTimer);
526
+ this.heartbeatTimer = null;
527
+ }
342
528
  for (const pending of this.pending.values()) {
343
529
  clearTimeout(pending.timer);
344
530
  pending.reject(error);
345
531
  }
346
532
  this.pending.clear();
347
533
  }
534
+
535
+ #startHeartbeat() {
536
+ const intervalMs = Number(this.heartbeatIntervalMs);
537
+ const timeoutMs = Number(this.heartbeatTimeoutMs);
538
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) return;
539
+ this.touchPresence({ force: true });
540
+ this.heartbeatTimer = setInterval(() => {
541
+ if (!this.isOpen()) {
542
+ this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.'));
543
+ return;
544
+ }
545
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0 && Date.now() - this.lastPongAt > timeoutMs) {
546
+ try { this.ws.terminate(); } catch {}
547
+ this.#closePending(new ExtensionBrowserUnavailableError('Extension browser heartbeat timed out.'));
548
+ return;
549
+ }
550
+ this.touchPresence();
551
+ try {
552
+ this.ws.ping();
553
+ } catch (error) {
554
+ this.#closePending(error);
555
+ }
556
+ }, intervalMs);
557
+ this.heartbeatTimer.unref?.();
558
+ }
348
559
  }
349
560
 
350
561
  module.exports = {
@@ -18,6 +18,7 @@ const DESKTOP_COMMANDS = Object.freeze({
18
18
  PAUSE_CONTROL: 'pauseControl',
19
19
  EXECUTE_COMMAND: 'executeCommand',
20
20
  PING: 'ping',
21
+ MOUSE_MOVE: 'mouseMove',
21
22
  });
22
23
 
23
24
  const FRAME_TYPE_VIDEO = 0x01;
@@ -148,6 +148,10 @@ class DesktopProvider {
148
148
  return this._dispatch(DESKTOP_COMMANDS.CLICK, { ...options, x, y });
149
149
  }
150
150
 
151
+ mouseMove(x, y, options = {}) {
152
+ return this._dispatch(DESKTOP_COMMANDS.MOUSE_MOVE, { ...options, x, y });
153
+ }
154
+
151
155
  drag(options = {}) {
152
156
  return this._dispatch(DESKTOP_COMMANDS.DRAG, options);
153
157
  }
@@ -10,6 +10,9 @@ const {
10
10
  } = require('./protocol');
11
11
 
12
12
  const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 1000;
13
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 25 * 1000;
14
+ const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75 * 1000;
15
+ const DEFAULT_PRESENCE_TOUCH_INTERVAL_MS = 15 * 1000;
13
16
 
14
17
  function safeJson(value) {
15
18
  try {
@@ -47,6 +50,21 @@ class DesktopCompanionRegistry {
47
50
  || process.env.NEOAGENT_DESKTOP_COMMAND_TIMEOUT_MS
48
51
  || DEFAULT_COMMAND_TIMEOUT_MS,
49
52
  );
53
+ this.heartbeatIntervalMs = Number(
54
+ options.heartbeatIntervalMs
55
+ || process.env.NEOAGENT_DESKTOP_HEARTBEAT_INTERVAL_MS
56
+ || DEFAULT_HEARTBEAT_INTERVAL_MS,
57
+ );
58
+ this.heartbeatTimeoutMs = Number(
59
+ options.heartbeatTimeoutMs
60
+ || process.env.NEOAGENT_DESKTOP_HEARTBEAT_TIMEOUT_MS
61
+ || DEFAULT_HEARTBEAT_TIMEOUT_MS,
62
+ );
63
+ this.presenceTouchIntervalMs = Number(
64
+ options.presenceTouchIntervalMs
65
+ || process.env.NEOAGENT_DESKTOP_PRESENCE_TOUCH_INTERVAL_MS
66
+ || DEFAULT_PRESENCE_TOUCH_INTERVAL_MS,
67
+ );
50
68
  this.connectionsByUser = new Map();
51
69
  }
52
70
 
@@ -200,6 +218,9 @@ class DesktopCompanionRegistry {
200
218
  platform: record.platform,
201
219
  },
202
220
  timeoutMs: this.commandTimeoutMs,
221
+ heartbeatIntervalMs: this.heartbeatIntervalMs,
222
+ heartbeatTimeoutMs: this.heartbeatTimeoutMs,
223
+ presenceTouchIntervalMs: this.presenceTouchIntervalMs,
203
224
  });
204
225
  // Install the new connection in the map BEFORE closing the old one.
205
226
  // This ensures that when the old socket's async 'close' event fires and
@@ -275,6 +296,19 @@ class DesktopCompanionRegistry {
275
296
  return this.getDeviceRecordByDeviceId(userId, deviceId);
276
297
  }
277
298
 
299
+ touchPresence(userId, deviceId) {
300
+ const userMap = this._getUserMap(userId);
301
+ const connection = userMap?.get(String(deviceId));
302
+ if (!connection?.isOpen()) return;
303
+ this.db.prepare(
304
+ `UPDATE desktop_companion_devices
305
+ SET status = 'online',
306
+ last_seen_at = datetime('now'),
307
+ updated_at = datetime('now')
308
+ WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL`
309
+ ).run(userId, deviceId);
310
+ }
311
+
278
312
  isConnected(userId) {
279
313
  const userMap = this._getUserMap(userId);
280
314
  return userMap != null && userMap.size > 0;
@@ -493,7 +527,19 @@ class DesktopCompanionRegistry {
493
527
  }
494
528
 
495
529
  class DesktopCompanionConnection {
496
- constructor({ registry, ws, userId, sessionId, deviceId, recordId, meta, timeoutMs }) {
530
+ constructor({
531
+ registry,
532
+ ws,
533
+ userId,
534
+ sessionId,
535
+ deviceId,
536
+ recordId,
537
+ meta,
538
+ timeoutMs,
539
+ heartbeatIntervalMs,
540
+ heartbeatTimeoutMs,
541
+ presenceTouchIntervalMs,
542
+ }) {
497
543
  this.registry = registry;
498
544
  this.ws = ws;
499
545
  this.userId = userId;
@@ -502,11 +548,22 @@ class DesktopCompanionConnection {
502
548
  this.recordId = recordId;
503
549
  this.meta = meta || {};
504
550
  this.timeoutMs = timeoutMs;
551
+ this.heartbeatIntervalMs = heartbeatIntervalMs;
552
+ this.heartbeatTimeoutMs = heartbeatTimeoutMs;
553
+ this.presenceTouchIntervalMs = presenceTouchIntervalMs;
505
554
  this.pending = new Map();
555
+ this.lastPongAt = Date.now();
556
+ this.lastPresenceTouchAt = 0;
557
+ this.heartbeatTimer = null;
506
558
 
507
559
  ws.on('message', (data) => this._handleMessage(data));
560
+ ws.on('pong', () => {
561
+ this.lastPongAt = Date.now();
562
+ this.touchPresence();
563
+ });
508
564
  ws.on('close', () => this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.')));
509
565
  ws.on('error', (error) => this._closePending(error));
566
+ this._startHeartbeat();
510
567
  }
511
568
 
512
569
  isOpen() {
@@ -525,6 +582,21 @@ class DesktopCompanionConnection {
525
582
  this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.'));
526
583
  }
527
584
 
585
+ touchPresence({ force = false } = {}) {
586
+ const now = Date.now();
587
+ const intervalMs = Number(this.presenceTouchIntervalMs);
588
+ if (
589
+ !force
590
+ && Number.isFinite(intervalMs)
591
+ && intervalMs > 0
592
+ && now - this.lastPresenceTouchAt < intervalMs
593
+ ) {
594
+ return;
595
+ }
596
+ this.lastPresenceTouchAt = now;
597
+ this.registry.touchPresence(this.userId, this.deviceId);
598
+ }
599
+
528
600
  sendCommand(command, payload = {}, options = {}) {
529
601
  if (!this.isOpen()) {
530
602
  return Promise.reject(new DesktopCompanionUnavailableError());
@@ -549,6 +621,8 @@ class DesktopCompanionConnection {
549
621
  }
550
622
 
551
623
  _handleMessage(data) {
624
+ this.lastPongAt = Date.now();
625
+ this.touchPresence();
552
626
  if (Buffer.isBuffer(data) && data.length > 0 && data[0] === FRAME_TYPE_VIDEO) {
553
627
  return;
554
628
  }
@@ -585,6 +659,10 @@ class DesktopCompanionConnection {
585
659
  }
586
660
 
587
661
  _closePending(error) {
662
+ if (this.heartbeatTimer) {
663
+ clearInterval(this.heartbeatTimer);
664
+ this.heartbeatTimer = null;
665
+ }
588
666
  for (const pending of this.pending.values()) {
589
667
  clearTimeout(pending.timer);
590
668
  pending.reject(error);
@@ -596,6 +674,31 @@ class DesktopCompanionConnection {
596
674
  // mark the device offline.
597
675
  this.registry.unregisterConnection(this);
598
676
  }
677
+
678
+ _startHeartbeat() {
679
+ const intervalMs = Number(this.heartbeatIntervalMs);
680
+ const timeoutMs = Number(this.heartbeatTimeoutMs);
681
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) return;
682
+ this.touchPresence({ force: true });
683
+ this.heartbeatTimer = setInterval(() => {
684
+ if (!this.isOpen()) {
685
+ this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.'));
686
+ return;
687
+ }
688
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0 && Date.now() - this.lastPongAt > timeoutMs) {
689
+ try { this.ws.terminate(); } catch {}
690
+ this._closePending(new DesktopCompanionUnavailableError('Desktop companion heartbeat timed out.'));
691
+ return;
692
+ }
693
+ this.touchPresence();
694
+ try {
695
+ this.ws.ping();
696
+ } catch (error) {
697
+ this._closePending(error);
698
+ }
699
+ }, intervalMs);
700
+ this.heartbeatTimer.unref?.();
701
+ }
599
702
  }
600
703
 
601
704
  module.exports = {