neoagent 2.4.1-beta.9 → 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 (234) 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 +36 -4
  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 -3
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +13 -0
  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 +2 -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 +197 -42
  52. package/flutter_app/lib/main_devices.dart +436 -4
  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 +673 -871
  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 +46 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +30 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +71 -1
  70. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  71. package/flutter_app/lib/src/stream_renderer.dart +42 -3
  72. package/flutter_app/lib/src/theme/palette.dart +76 -34
  73. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  74. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  75. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  81. package/flutter_app/pubspec.lock +2 -2
  82. package/flutter_app/pubspec.yaml +7 -1
  83. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  84. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  85. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  94. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  95. package/flutter_app/web/favicon.png +0 -0
  96. package/flutter_app/web/favicon.svg +40 -9
  97. package/flutter_app/web/favicon_light.svg +43 -0
  98. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  99. package/flutter_app/web/icons/Icon-192.png +0 -0
  100. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-512.png +0 -0
  102. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  106. package/flutter_app/windows/runner/main.cpp +7 -1
  107. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  108. package/lib/manager.js +445 -83
  109. package/package.json +17 -3
  110. package/runtime/paths.js +3 -1
  111. package/server/admin/access.js +198 -0
  112. package/server/admin/admin.css +268 -0
  113. package/server/admin/admin.js +348 -0
  114. package/server/admin/analytics.js +128 -0
  115. package/server/admin/index.html +1015 -0
  116. package/server/admin/login.html +290 -0
  117. package/server/admin/logo.svg +43 -0
  118. package/server/admin/sql.js +134 -0
  119. package/server/admin/users.js +147 -0
  120. package/server/config/origins.js +3 -1
  121. package/server/db/database.js +92 -0
  122. package/server/db/ftsQuery.js +27 -0
  123. package/server/http/routes.js +1 -0
  124. package/server/http/static.js +23 -6
  125. package/server/index.js +1 -1
  126. package/server/middleware/adminAuth.js +48 -0
  127. package/server/middleware/auth.js +1 -40
  128. package/server/public/.last_build_id +1 -1
  129. package/server/public/app_icon.png +0 -0
  130. package/server/public/assets/AssetManifest.bin +1 -1
  131. package/server/public/assets/AssetManifest.bin.json +1 -1
  132. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  133. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  134. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  138. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  139. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  140. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  141. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  142. package/server/public/assets/web/icons/Icon-192.png +0 -0
  143. package/server/public/favicon.png +0 -0
  144. package/server/public/favicon.svg +40 -9
  145. package/server/public/favicon_light.svg +43 -0
  146. package/server/public/flutter_bootstrap.js +2 -2
  147. package/server/public/icons/Icon-192-light.png +0 -0
  148. package/server/public/icons/Icon-192.png +0 -0
  149. package/server/public/icons/Icon-512-light.png +0 -0
  150. package/server/public/icons/Icon-512.png +0 -0
  151. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  152. package/server/public/icons/Icon-maskable-192.png +0 -0
  153. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-512.png +0 -0
  155. package/server/public/main.dart.js +88537 -85936
  156. package/server/routes/admin.js +632 -0
  157. package/server/routes/agent_profiles.js +3 -0
  158. package/server/routes/agents.js +1 -1
  159. package/server/routes/auth.js +3 -1
  160. package/server/routes/mcp.js +29 -13
  161. package/server/routes/memory.js +23 -1
  162. package/server/routes/messaging.js +2 -0
  163. package/server/routes/screenHistory.js +14 -8
  164. package/server/routes/settings.js +10 -2
  165. package/server/routes/stream.js +1 -1
  166. package/server/routes/triggers.js +4 -4
  167. package/server/routes/voice_assistant.js +36 -1
  168. package/server/routes/workspace.js +86 -0
  169. package/server/services/account/admin_two_factor.js +132 -0
  170. package/server/services/account/password_policy.js +6 -1
  171. package/server/services/ai/completion.js +44 -0
  172. package/server/services/ai/engine.js +321 -500
  173. package/server/services/ai/imageAnalysis.js +9 -5
  174. package/server/services/ai/logFormat.js +46 -0
  175. package/server/services/ai/loopPolicy.js +11 -0
  176. package/server/services/ai/messagingFallback.js +228 -0
  177. package/server/services/ai/models.js +194 -55
  178. package/server/services/ai/providerRetry.js +169 -0
  179. package/server/services/ai/providers/anthropic.js +15 -4
  180. package/server/services/ai/providers/google.js +38 -11
  181. package/server/services/ai/providers/grok.js +19 -68
  182. package/server/services/ai/providers/grokOauth.js +141 -0
  183. package/server/services/ai/providers/nvidia.js +154 -0
  184. package/server/services/ai/providers/ollama.js +76 -39
  185. package/server/services/ai/providers/openai.js +20 -31
  186. package/server/services/ai/providers/openaiCodex.js +6 -2
  187. package/server/services/ai/providers/openaiCompatible.js +70 -0
  188. package/server/services/ai/providers/openrouter.js +162 -0
  189. package/server/services/ai/settings.js +30 -0
  190. package/server/services/ai/systemPrompt.js +51 -29
  191. package/server/services/ai/taskAnalysis.js +60 -1
  192. package/server/services/ai/toolEvidence.js +207 -0
  193. package/server/services/ai/toolSelector.js +8 -1
  194. package/server/services/ai/tools.js +78 -13
  195. package/server/services/browser/extension/registry.js +94 -9
  196. package/server/services/desktop/registry.js +104 -1
  197. package/server/services/desktop/screenRecorder.js +208 -98
  198. package/server/services/desktop/screen_recorder_support.js +46 -0
  199. package/server/services/integrations/google/provider.js +13 -0
  200. package/server/services/integrations/home_assistant/constants.js +88 -0
  201. package/server/services/integrations/home_assistant/network.js +207 -0
  202. package/server/services/integrations/home_assistant/provider.js +249 -0
  203. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  204. package/server/services/integrations/home_assistant/tools.js +101 -0
  205. package/server/services/integrations/manager.js +5 -0
  206. package/server/services/integrations/registry.js +2 -0
  207. package/server/services/integrations/trello/provider.js +8 -2
  208. package/server/services/manager.js +51 -53
  209. package/server/services/mcp/client.js +208 -268
  210. package/server/services/mcp/client_support.js +172 -0
  211. package/server/services/mcp/recovery.js +116 -0
  212. package/server/services/mcp/tool_operations.js +123 -0
  213. package/server/services/memory/ingestion.js +185 -370
  214. package/server/services/memory/ingestion_coverage.js +129 -0
  215. package/server/services/memory/ingestion_documents.js +123 -0
  216. package/server/services/memory/ingestion_support.js +171 -0
  217. package/server/services/memory/intelligence.js +181 -0
  218. package/server/services/memory/manager.js +476 -32
  219. package/server/services/messaging/automation.js +71 -134
  220. package/server/services/messaging/formatting_guides.js +26 -1
  221. package/server/services/messaging/inbound_queue.js +111 -0
  222. package/server/services/messaging/manager.js +25 -8
  223. package/server/services/messaging/typing_keepalive.js +110 -0
  224. package/server/services/streaming/android-stream.js +293 -40
  225. package/server/services/tasks/integration_runtime.js +4 -1
  226. package/server/services/tasks/runtime.js +415 -56
  227. package/server/services/tasks/task_repository.js +48 -6
  228. package/server/services/websocket.js +8 -2
  229. package/server/services/widgets/service.js +17 -1
  230. package/server/services/workspace/manager.js +116 -0
  231. package/server/utils/logger.js +44 -6
  232. package/server/utils/security.js +3 -0
  233. package/server/utils/version.js +29 -19
  234. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -274,6 +274,42 @@ function markProactiveMessageSent({ runState, deliveryState, content }) {
274
274
  }
275
275
  }
276
276
 
277
+ function markProactiveNoResponse({ runState, deliveryState }) {
278
+ if (runState) {
279
+ runState.noResponse = true;
280
+ }
281
+ if (deliveryState) {
282
+ deliveryState.noResponse = true;
283
+ }
284
+ }
285
+
286
+ function normalizeStoredSettingString(value) {
287
+ if (value == null) return '';
288
+ if (typeof value !== 'string') return String(value || '').trim();
289
+ let current = value.trim();
290
+ for (let i = 0; i < 2; i += 1) {
291
+ if (!current) return '';
292
+ try {
293
+ const parsed = JSON.parse(current);
294
+ if (typeof parsed === 'string') {
295
+ current = parsed.trim();
296
+ continue;
297
+ }
298
+ return '';
299
+ } catch {
300
+ return current;
301
+ }
302
+ }
303
+ return current;
304
+ }
305
+
306
+ function normalizeMessagingTarget(target = {}) {
307
+ const platform = normalizeStoredSettingString(target.platform);
308
+ const to = normalizeStoredSettingString(target.to);
309
+ if (!platform || !to) return null;
310
+ return { platform, to };
311
+ }
312
+
277
313
  function buildAndroidUiMatchProperties(extra = {}) {
278
314
  return {
279
315
  x: { type: 'number', description: 'Absolute X coordinate' },
@@ -1289,7 +1325,7 @@ function getAvailableTools(app, options = {}) {
1289
1325
  // mechanism and gives the AI real agency over when it's finished.
1290
1326
  tools.push({
1291
1327
  name: 'task_complete',
1292
- description: 'Signal that the task is fully complete and provide the final response. Call this exactly once when all steps are done and you have a complete answer ready. Do NOT call it if you still have work to do.',
1328
+ description: 'Signal that the task is fully complete and provide the final response. Call this exactly once when all steps are done and you have a complete answer ready. Do NOT call it if you still have work to do, unverified claims, unresolved tool failures, or confidence below the current run requirement.',
1293
1329
  parameters: {
1294
1330
  type: 'object',
1295
1331
  properties: {
@@ -1300,10 +1336,10 @@ function getAvailableTools(app, options = {}) {
1300
1336
  confidence: {
1301
1337
  type: 'string',
1302
1338
  enum: ['high', 'medium', 'low'],
1303
- description: 'How confident are you the task is fully and correctly complete? Use "low" if you had to make assumptions.'
1339
+ description: 'How confident are you the task is fully and correctly complete? Use "low" only when the final answer is intentionally limited or incomplete; low confidence may be rejected so the run can keep working.'
1304
1340
  }
1305
1341
  },
1306
- required: ['message']
1342
+ required: ['message', 'confidence']
1307
1343
  }
1308
1344
  });
1309
1345
 
@@ -2112,6 +2148,9 @@ async function executeTool(toolName, args, context, engine) {
2112
2148
  error: proactiveValidation.error,
2113
2149
  };
2114
2150
  }
2151
+ if (proactiveValidation.reason === 'no_response') {
2152
+ markProactiveNoResponse({ runState, deliveryState });
2153
+ }
2115
2154
  return {
2116
2155
  sent: false,
2117
2156
  suppressed: proactiveValidation.suppressed === true,
@@ -2323,8 +2362,8 @@ async function executeTool(toolName, args, context, engine) {
2323
2362
  || null
2324
2363
  );
2325
2364
  const loadDefaultTarget = () => ({
2326
- platform: loadAgentSetting('last_platform'),
2327
- to: loadAgentSetting('last_chat_id')
2365
+ platform: normalizeStoredSettingString(loadAgentSetting('last_platform')),
2366
+ to: normalizeStoredSettingString(loadAgentSetting('last_chat_id'))
2328
2367
  });
2329
2368
 
2330
2369
  let taskConfig = null;
@@ -2336,26 +2375,43 @@ async function executeTool(toolName, args, context, engine) {
2336
2375
  try {
2337
2376
  taskConfig = JSON.parse(task.task_config || '{}');
2338
2377
  taskTarget = {
2339
- platform: taskConfig.notifyPlatform || null,
2340
- to: taskConfig.notifyTo || null
2378
+ platform: normalizeStoredSettingString(taskConfig.notifyPlatform),
2379
+ to: normalizeStoredSettingString(taskConfig.notifyTo)
2341
2380
  };
2342
2381
  } catch { }
2343
2382
  }
2344
2383
  }
2345
2384
 
2346
2385
  const fallbackTarget = loadDefaultTarget();
2386
+ const recentTargets = db.prepare(
2387
+ `SELECT platform, platform_chat_id
2388
+ FROM messages
2389
+ WHERE user_id = ?
2390
+ AND agent_id = ?
2391
+ AND platform IS NOT NULL
2392
+ AND platform_chat_id IS NOT NULL
2393
+ ORDER BY id DESC
2394
+ LIMIT 20`
2395
+ ).all(userId, agentId);
2347
2396
  const candidateTargets = [];
2348
2397
  const seenTargets = new Set();
2349
2398
  const addCandidate = (target) => {
2350
- if (!target?.platform || !target?.to) return;
2351
- const key = `${target.platform}:${target.to}`;
2399
+ const normalizedTarget = normalizeMessagingTarget(target);
2400
+ if (!normalizedTarget) return;
2401
+ const key = `${normalizedTarget.platform}:${normalizedTarget.to}`;
2352
2402
  if (seenTargets.has(key)) return;
2353
2403
  seenTargets.add(key);
2354
- candidateTargets.push(target);
2404
+ candidateTargets.push(normalizedTarget);
2355
2405
  };
2356
2406
 
2357
2407
  addCandidate(taskTarget);
2358
2408
  addCandidate(fallbackTarget);
2409
+ for (const row of recentTargets) {
2410
+ addCandidate({
2411
+ platform: row.platform,
2412
+ to: row.platform_chat_id
2413
+ });
2414
+ }
2359
2415
 
2360
2416
  if (candidateTargets.length === 0) {
2361
2417
  throw new Error('No messaging target is configured for this task run. Connect a platform and send at least one message on this server, or recreate the task after reconnecting.');
@@ -2611,8 +2667,14 @@ async function executeTool(toolName, args, context, engine) {
2611
2667
  let tools = [];
2612
2668
  if (autoStart) {
2613
2669
  try {
2614
- await mcpClient.startServer(serverId, args.command, args.name, userId, { agentId });
2615
- tools = await mcpClient.listTools(serverId, userId);
2670
+ const startResult = await mcpClient.startServer(
2671
+ serverId,
2672
+ args.command,
2673
+ args.name,
2674
+ userId,
2675
+ { agentId }
2676
+ );
2677
+ tools = startResult.tools || [];
2616
2678
  } catch (startErr) {
2617
2679
  return { registered: true, id: serverId, started: false, error: `Registered but failed to start: ${startErr.message}` };
2618
2680
  }
@@ -2635,7 +2697,10 @@ async function executeTool(toolName, args, context, engine) {
2635
2697
  args: JSON.parse(s.config || '{}').args || [],
2636
2698
  enabled: !!s.enabled,
2637
2699
  status: liveStatuses[s.id]?.status || 'stopped',
2638
- toolCount: liveStatuses[s.id]?.toolCount || 0
2700
+ toolCount: liveStatuses[s.id]?.toolCount || 0,
2701
+ error: liveStatuses[s.id]?.error || null,
2702
+ consecutiveFails: liveStatuses[s.id]?.consecutiveFails || 0,
2703
+ nextRetryAt: liveStatuses[s.id]?.nextRetryAt || null
2639
2704
  }))
2640
2705
  };
2641
2706
  }
@@ -10,6 +10,7 @@ const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000;
10
10
  const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 1000;
11
11
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 25 * 1000;
12
12
  const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75 * 1000;
13
+ const DEFAULT_PRESENCE_TOUCH_INTERVAL_MS = 15 * 1000;
13
14
 
14
15
  function sha256(value) {
15
16
  return crypto.createHash('sha256').update(String(value || '')).digest('hex');
@@ -46,6 +47,7 @@ class BrowserExtensionRegistry {
46
47
  this.pairingTtlMs = Number(options.pairingTtlMs || process.env.NEOAGENT_BROWSER_EXTENSION_PAIRING_TTL_MS || DEFAULT_PAIRING_TTL_MS);
47
48
  this.heartbeatIntervalMs = Number(options.heartbeatIntervalMs || process.env.NEOAGENT_BROWSER_EXTENSION_HEARTBEAT_INTERVAL_MS || DEFAULT_HEARTBEAT_INTERVAL_MS);
48
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);
49
51
  this.connectionsByUser = new Map();
50
52
  }
51
53
 
@@ -142,16 +144,42 @@ class BrowserExtensionRegistry {
142
144
  };
143
145
 
144
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
+
145
164
  this.db.prepare(
146
165
  `UPDATE browser_extension_pairing_requests
147
166
  SET status = 'claimed', claimed_at = datetime('now')
148
167
  WHERE id = ?`
149
168
  ).run(row.id);
169
+
150
170
  this.db.prepare(
151
171
  `INSERT INTO browser_extension_tokens (
152
172
  id, user_id, token_hash, name, status, metadata_json
153
173
  ) VALUES (?, ?, ?, ?, 'active', ?)`
154
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);
155
183
  });
156
184
  tx();
157
185
 
@@ -192,6 +220,7 @@ class BrowserExtensionRegistry {
192
220
  timeoutMs: this.commandTimeoutMs,
193
221
  heartbeatIntervalMs: this.heartbeatIntervalMs,
194
222
  heartbeatTimeoutMs: this.heartbeatTimeoutMs,
223
+ presenceTouchIntervalMs: this.presenceTouchIntervalMs,
195
224
  });
196
225
  userMap.set(tokenRow.id, connection);
197
226
  this.db.prepare(
@@ -260,17 +289,44 @@ class BrowserExtensionRegistry {
260
289
  if (selected && userMap.get(selected)?.isOpen()) {
261
290
  return userMap.get(selected);
262
291
  }
263
- if (selected) return null;
292
+
293
+ // Auto-select online connection if selected is offline/unset
264
294
  const online = Array.from(userMap.values()).filter((connection) => connection.isOpen());
265
- if (online.length === 0) return null;
266
- online.sort((a, b) => String(b.connectedAt || '').localeCompare(String(a.connectedAt || '')));
267
- return online[0];
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;
309
+ }
310
+
311
+ if (selected) {
312
+ return userMap.get(selected) || null;
313
+ }
314
+ return null;
268
315
  }
269
316
 
270
317
  isConnected(userId, tokenId = null) {
271
318
  return Boolean(this.getConnection(userId, tokenId)?.isOpen());
272
319
  }
273
320
 
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);
328
+ }
329
+
274
330
  async dispatch(userId, command, payload = {}, options = {}) {
275
331
  const connection = this.getConnection(userId, options.tokenId || payload.tokenId || null);
276
332
  if (!connection || !connection.isOpen()) {
@@ -285,10 +341,16 @@ class BrowserExtensionRegistry {
285
341
 
286
342
  getStatus(userId) {
287
343
  const userMap = this.#getUserConnections(userId);
288
- const selectedTokenId = this.getSelectedTokenId(userId);
289
- const connected = selectedTokenId
344
+ let selectedTokenId = this.getSelectedTokenId(userId);
345
+ let connected = selectedTokenId
290
346
  ? userMap?.get(selectedTokenId)
291
- : this.getConnection(userId);
347
+ : null;
348
+ if (!connected?.isOpen()) {
349
+ connected = this.getConnection(userId);
350
+ if (connected?.isOpen()) {
351
+ selectedTokenId = connected.tokenId;
352
+ }
353
+ }
292
354
  const effectiveSelectedTokenId = selectedTokenId || connected?.tokenId || null;
293
355
  const tokens = this.db.prepare(
294
356
  `SELECT id, name, status, last_connected_at, last_seen_at, revoked_at, created_at, metadata_json
@@ -356,7 +418,7 @@ class BrowserExtensionRegistry {
356
418
  }
357
419
 
358
420
  class ExtensionBrowserConnection {
359
- constructor({ registry, ws, userId, tokenId, meta, timeoutMs, heartbeatIntervalMs, heartbeatTimeoutMs }) {
421
+ constructor({ registry, ws, userId, tokenId, meta, timeoutMs, heartbeatIntervalMs, heartbeatTimeoutMs, presenceTouchIntervalMs }) {
360
422
  this.registry = registry;
361
423
  this.ws = ws;
362
424
  this.userId = userId;
@@ -365,13 +427,18 @@ class ExtensionBrowserConnection {
365
427
  this.timeoutMs = timeoutMs;
366
428
  this.heartbeatIntervalMs = heartbeatIntervalMs;
367
429
  this.heartbeatTimeoutMs = heartbeatTimeoutMs;
430
+ this.presenceTouchIntervalMs = presenceTouchIntervalMs;
368
431
  this.pending = new Map();
369
432
  this.connectedAt = new Date().toISOString();
370
433
  this.lastPongAt = Date.now();
434
+ this.lastPresenceTouchAt = 0;
371
435
  this.heartbeatTimer = null;
372
436
 
373
437
  ws.on('message', (data) => this.#handleMessage(data));
374
- ws.on('pong', () => { this.lastPongAt = Date.now(); });
438
+ ws.on('pong', () => {
439
+ this.lastPongAt = Date.now();
440
+ this.touchPresence();
441
+ });
375
442
  ws.on('close', () => this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.')));
376
443
  ws.on('error', (error) => this.#closePending(error));
377
444
  this.#startHeartbeat();
@@ -391,6 +458,21 @@ class ExtensionBrowserConnection {
391
458
  this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.'));
392
459
  }
393
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
+
394
476
  sendCommand(command, payload = {}, options = {}) {
395
477
  if (!this.isOpen()) {
396
478
  return Promise.reject(new ExtensionBrowserUnavailableError());
@@ -416,6 +498,7 @@ class ExtensionBrowserConnection {
416
498
 
417
499
  #handleMessage(data) {
418
500
  this.lastPongAt = Date.now();
501
+ this.touchPresence();
419
502
  let message;
420
503
  try {
421
504
  message = parseExtensionMessage(data);
@@ -453,6 +536,7 @@ class ExtensionBrowserConnection {
453
536
  const intervalMs = Number(this.heartbeatIntervalMs);
454
537
  const timeoutMs = Number(this.heartbeatTimeoutMs);
455
538
  if (!Number.isFinite(intervalMs) || intervalMs <= 0) return;
539
+ this.touchPresence({ force: true });
456
540
  this.heartbeatTimer = setInterval(() => {
457
541
  if (!this.isOpen()) {
458
542
  this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.'));
@@ -463,6 +547,7 @@ class ExtensionBrowserConnection {
463
547
  this.#closePending(new ExtensionBrowserUnavailableError('Extension browser heartbeat timed out.'));
464
548
  return;
465
549
  }
550
+ this.touchPresence();
466
551
  try {
467
552
  this.ws.ping();
468
553
  } catch (error) {
@@ -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 = {