neoagent 3.2.1-beta.2 → 3.2.1-beta.4

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 (145) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +511 -89
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +29 -20
  6. package/flutter_app/lib/main_models.dart +3 -0
  7. package/flutter_app/lib/main_operations.dart +334 -321
  8. package/flutter_app/lib/main_settings.dart +4 -3
  9. package/flutter_app/lib/main_shared.dart +14 -11
  10. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  11. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  12. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  13. package/lib/manager.js +106 -89
  14. package/lib/schema_migrations.js +67 -13
  15. package/package.json +20 -13
  16. package/runtime/paths.js +49 -5
  17. package/server/guest_agent.js +52 -30
  18. package/server/http/middleware.js +24 -0
  19. package/server/http/routes.js +4 -6
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  22. package/server/public/flutter_bootstrap.js +1 -1
  23. package/server/public/main.dart.js +30803 -30805
  24. package/server/routes/admin.js +1 -1
  25. package/server/routes/android.js +30 -34
  26. package/server/routes/browser.js +23 -15
  27. package/server/routes/desktop.js +18 -1
  28. package/server/routes/integrations.js +5 -1
  29. package/server/routes/memory.js +1 -0
  30. package/server/routes/settings.js +16 -5
  31. package/server/routes/social_reach.js +12 -3
  32. package/server/routes/social_video.js +4 -0
  33. package/server/services/ai/compaction.js +7 -2
  34. package/server/services/ai/history.js +44 -5
  35. package/server/services/ai/integrated_tools/http_request.js +8 -0
  36. package/server/services/ai/loop/agent_engine_core.js +347 -162
  37. package/server/services/ai/loop/callbacks.js +1 -0
  38. package/server/services/ai/loop/completion_judge.js +12 -0
  39. package/server/services/ai/loop/conversation_loop.js +348 -242
  40. package/server/services/ai/loop/messaging_delivery.js +129 -57
  41. package/server/services/ai/loop/model_call_guard.js +91 -0
  42. package/server/services/ai/loop/model_io.js +8 -41
  43. package/server/services/ai/loop/tool_dispatch.js +19 -8
  44. package/server/services/ai/loopPolicy.js +24 -19
  45. package/server/services/ai/model_discovery.js +227 -0
  46. package/server/services/ai/model_identity.js +71 -0
  47. package/server/services/ai/models.js +67 -162
  48. package/server/services/ai/providerRetry.js +17 -59
  49. package/server/services/ai/provider_selector.js +111 -0
  50. package/server/services/ai/providers/anthropic.js +2 -2
  51. package/server/services/ai/providers/claudeCode.js +15 -28
  52. package/server/services/ai/providers/githubCopilot.js +36 -16
  53. package/server/services/ai/providers/google.js +23 -5
  54. package/server/services/ai/providers/grok.js +4 -3
  55. package/server/services/ai/providers/grokOauth.js +13 -22
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +102 -82
  58. package/server/services/ai/providers/ollama_stream.js +142 -0
  59. package/server/services/ai/providers/openai.js +39 -5
  60. package/server/services/ai/providers/openaiCodex.js +11 -4
  61. package/server/services/ai/providers/openrouter.js +29 -7
  62. package/server/services/ai/providers/provider_error.js +36 -0
  63. package/server/services/ai/settings.js +26 -2
  64. package/server/services/ai/taskAnalysis.js +5 -1
  65. package/server/services/ai/terminal_reply.js +45 -0
  66. package/server/services/ai/toolEvidence.js +58 -29
  67. package/server/services/ai/tools.js +124 -111
  68. package/server/services/android/controller.js +770 -237
  69. package/server/services/android/process.js +140 -0
  70. package/server/services/android/sdk_download.js +143 -0
  71. package/server/services/android/uia.js +6 -5
  72. package/server/services/artifacts/store.js +24 -0
  73. package/server/services/browser/controller.js +736 -385
  74. package/server/services/browser/extension/gateway.js +40 -16
  75. package/server/services/browser/extension/protocol.js +12 -1
  76. package/server/services/browser/extension/provider.js +59 -47
  77. package/server/services/browser/extension/registry.js +155 -34
  78. package/server/services/cli/executor.js +62 -9
  79. package/server/services/desktop/gateway.js +41 -4
  80. package/server/services/desktop/protocol.js +3 -0
  81. package/server/services/desktop/provider.js +39 -42
  82. package/server/services/desktop/registry.js +137 -52
  83. package/server/services/integrations/figma/provider.js +78 -12
  84. package/server/services/integrations/github/common.js +11 -6
  85. package/server/services/integrations/github/provider.js +52 -53
  86. package/server/services/integrations/google/provider.js +55 -19
  87. package/server/services/integrations/home_assistant/network.js +17 -20
  88. package/server/services/integrations/home_assistant/provider.js +7 -5
  89. package/server/services/integrations/home_assistant/tools.js +17 -5
  90. package/server/services/integrations/http.js +51 -0
  91. package/server/services/integrations/manager.js +158 -53
  92. package/server/services/integrations/microsoft/provider.js +80 -13
  93. package/server/services/integrations/neoarchive/provider.js +55 -29
  94. package/server/services/integrations/neorecall/client.js +17 -10
  95. package/server/services/integrations/neorecall/provider.js +20 -11
  96. package/server/services/integrations/notion/provider.js +16 -13
  97. package/server/services/integrations/oauth_provider.js +115 -51
  98. package/server/services/integrations/slack/provider.js +98 -9
  99. package/server/services/integrations/spotify/provider.js +67 -71
  100. package/server/services/integrations/trello/provider.js +21 -7
  101. package/server/services/integrations/weather/provider.js +18 -12
  102. package/server/services/integrations/whatsapp/provider.js +76 -16
  103. package/server/services/manager.js +87 -1
  104. package/server/services/memory/embedding_index.js +20 -8
  105. package/server/services/memory/embeddings.js +151 -90
  106. package/server/services/memory/ingestion.js +50 -9
  107. package/server/services/memory/ingestion_documents.js +13 -3
  108. package/server/services/memory/manager.js +52 -19
  109. package/server/services/messaging/automation.js +84 -9
  110. package/server/services/messaging/http_platforms.js +33 -13
  111. package/server/services/messaging/inbound_queue.js +78 -24
  112. package/server/services/messaging/inbound_store.js +224 -0
  113. package/server/services/messaging/manager.js +326 -51
  114. package/server/services/messaging/typing_keepalive.js +5 -2
  115. package/server/services/messaging/whatsapp.js +22 -14
  116. package/server/services/network/http.js +210 -0
  117. package/server/services/network/safe_request.js +307 -0
  118. package/server/services/runtime/backends/local-vm.js +214 -66
  119. package/server/services/runtime/manager.js +17 -12
  120. package/server/services/social_reach/channels/github.js +10 -4
  121. package/server/services/social_reach/channels/reddit.js +4 -4
  122. package/server/services/social_reach/channels/rss.js +2 -2
  123. package/server/services/social_reach/channels/social_video.js +12 -7
  124. package/server/services/social_reach/channels/v2ex.js +21 -8
  125. package/server/services/social_reach/channels/x.js +2 -2
  126. package/server/services/social_reach/channels/xueqiu.js +5 -5
  127. package/server/services/social_reach/service.js +9 -6
  128. package/server/services/social_reach/utils.js +65 -14
  129. package/server/services/social_video/service.js +160 -50
  130. package/server/services/tasks/integration_runtime.js +18 -8
  131. package/server/services/tasks/runtime.js +39 -4
  132. package/server/services/voice/agentBridge.js +17 -4
  133. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  134. package/server/services/voice/liveSession.js +31 -0
  135. package/server/services/voice/openaiSpeech.js +33 -8
  136. package/server/services/voice/providers.js +233 -151
  137. package/server/services/voice/runtimeManager.js +118 -20
  138. package/server/services/voice/turnRunner.js +6 -0
  139. package/server/services/wearable/firmware_manifest.js +51 -13
  140. package/server/services/wearable/service.js +1 -0
  141. package/server/utils/abort.js +96 -0
  142. package/server/utils/cloud-security.js +110 -3
  143. package/server/utils/files.js +31 -0
  144. package/server/utils/image_payload.js +95 -0
  145. package/server/utils/retry.js +107 -0
@@ -26,6 +26,7 @@ class DesktopCompanionManager extends ChangeNotifier {
26
26
  WebSocket? _socket;
27
27
  Timer? _reconnectTimer;
28
28
  Timer? _connectionWatchdogTimer;
29
+ Timer? _helloTimer;
29
30
  Timer? _streamTimer;
30
31
  bool _streamCaptureInFlight = false;
31
32
  // Set true while a click / drag / scroll / typeText / pressKey command is
@@ -38,6 +39,13 @@ class DesktopCompanionManager extends ChangeNotifier {
38
39
  // Tracks the current stream quality so the forced post-input capture can use
39
40
  // the same setting without re-parsing the original startStream payload.
40
41
  int _currentStreamQuality = 80;
42
+ Future<void> _inputCommandQueue = Future<void>.value();
43
+ final Set<String> _pendingCommandIds = <String>{};
44
+ final Set<String> _pendingShellCommandIds = <String>{};
45
+ final Set<String> _cancelledCommandIds = <String>{};
46
+ int _connectionGeneration = 0;
47
+ int _reconnectAttempt = 0;
48
+ bool _disposed = false;
41
49
 
42
50
  String _backendUrl = '';
43
51
  String _sessionCookie = '';
@@ -63,6 +71,10 @@ class DesktopCompanionManager extends ChangeNotifier {
63
71
  String get activationId => _activationId;
64
72
  Map<String, Object?> get status => _status;
65
73
 
74
+ void _notify() {
75
+ if (!_disposed) notifyListeners();
76
+ }
77
+
66
78
  Future<void> bootstrap(SharedPreferences prefs) async {
67
79
  _enabled = prefs.getBool(desktopCompanionEnabledPrefsKey) ?? false;
68
80
  // Always start unpaused — paused state must not carry over across restarts.
@@ -88,13 +100,20 @@ class DesktopCompanionManager extends ChangeNotifier {
88
100
  required String sessionCookie,
89
101
  required bool authenticated,
90
102
  }) async {
91
- _backendUrl = backendUrl.trim();
92
- _sessionCookie = sessionCookie.trim();
103
+ final nextBackendUrl = backendUrl.trim();
104
+ final nextSessionCookie = sessionCookie.trim();
105
+ final sessionChanged =
106
+ _backendUrl != nextBackendUrl || _sessionCookie != nextSessionCookie;
107
+ _backendUrl = nextBackendUrl;
108
+ _sessionCookie = nextSessionCookie;
93
109
  _authenticated = authenticated;
94
110
  if (!_authenticated || !_enabled || _sessionCookie.isEmpty) {
95
111
  await disconnect();
96
112
  return;
97
113
  }
114
+ if (sessionChanged && (_connected || _connecting || _socket != null)) {
115
+ await disconnect();
116
+ }
98
117
  _ensureConnectionWatchdog();
99
118
  await _ensureConnected();
100
119
  }
@@ -110,7 +129,7 @@ class DesktopCompanionManager extends ChangeNotifier {
110
129
  );
111
130
  }
112
131
  await prefs.setBool(desktopCompanionEnabledPrefsKey, value);
113
- notifyListeners();
132
+ _notify();
114
133
  if (!value) {
115
134
  await disconnect();
116
135
  return;
@@ -122,7 +141,7 @@ class DesktopCompanionManager extends ChangeNotifier {
122
141
  final normalized = value.trim().isEmpty ? _defaultLabel() : value.trim();
123
142
  _label = normalized;
124
143
  await prefs.setString(desktopCompanionLabelPrefsKey, normalized);
125
- notifyListeners();
144
+ _notify();
126
145
  if (_connected) {
127
146
  _status = {..._status, 'label': normalized};
128
147
  await _sendEvent('statusChanged', <String, Object?>{'label': normalized});
@@ -131,28 +150,30 @@ class DesktopCompanionManager extends ChangeNotifier {
131
150
 
132
151
  Future<void> setPaused(bool value, SharedPreferences prefs) async {
133
152
  _paused = value;
134
- notifyListeners();
153
+ _notify();
135
154
  if (_connected) {
136
155
  await _sendEvent('statusChanged', <String, Object?>{'paused': value});
137
156
  }
138
157
  }
139
158
 
140
159
  Future<void> disconnect() async {
160
+ _connectionGeneration++;
141
161
  _reconnectTimer?.cancel();
142
162
  _reconnectTimer = null;
143
163
  _connectionWatchdogTimer?.cancel();
144
164
  _connectionWatchdogTimer = null;
165
+ _helloTimer?.cancel();
166
+ _helloTimer = null;
145
167
  _stopStreaming();
146
168
  _connecting = false;
147
169
  _connected = false;
170
+ _cancelPendingCommands();
148
171
  final socket = _socket;
149
172
  _socket = null;
150
173
  if (socket != null) {
151
- try {
152
- await socket.close();
153
- } catch (_) {}
174
+ await _closeSocket(socket);
154
175
  }
155
- notifyListeners();
176
+ _notify();
156
177
  }
157
178
 
158
179
  Future<void> rotateIdentity(SharedPreferences prefs) async {
@@ -181,7 +202,7 @@ class DesktopCompanionManager extends ChangeNotifier {
181
202
  'companionEnabled': _enabled,
182
203
  'paused': _paused,
183
204
  };
184
- notifyListeners();
205
+ _notify();
185
206
  if (_connected) {
186
207
  await _sendEvent('statusChanged', <String, Object?>{
187
208
  'permissions': _status['permissions'],
@@ -217,65 +238,122 @@ class DesktopCompanionManager extends ChangeNotifier {
217
238
  }
218
239
 
219
240
  Future<void> _ensureConnected() async {
220
- if (!_enabled || !_authenticated || _sessionCookie.isEmpty) return;
241
+ if (_disposed || !_enabled || !_authenticated || _sessionCookie.isEmpty) {
242
+ return;
243
+ }
221
244
  if (_connecting || _connected) return;
245
+ final generation = ++_connectionGeneration;
222
246
  _connecting = true;
223
247
  _errorMessage = null;
224
- notifyListeners();
248
+ _notify();
249
+ WebSocket? connectedSocket;
225
250
  try {
226
251
  final uri = _desktopWsUri(_backendUrl);
227
- final socket = await WebSocket.connect(
228
- uri.toString(),
229
- headers: <String, Object>{'Cookie': _sessionCookie},
230
- );
252
+ final socket = await _openWebSocket(uri);
253
+ connectedSocket = socket;
254
+ if (_disposed || generation != _connectionGeneration) {
255
+ await _closeSocket(socket);
256
+ return;
257
+ }
231
258
  socket.pingInterval = const Duration(seconds: 25);
232
259
  _socket = socket;
233
260
  socket.listen(
234
- _handleMessage,
235
- onDone: _handleSocketClosed,
261
+ (dynamic raw) => _handleMessage(socket, generation, raw),
262
+ onDone: () => _handleSocketClosed(socket, generation),
236
263
  onError: (Object error, StackTrace stackTrace) {
237
- _errorMessage = '$error';
238
- _handleSocketClosed();
264
+ if (_socket == socket && generation == _connectionGeneration) {
265
+ _errorMessage = '$error';
266
+ }
267
+ _handleSocketClosed(socket, generation);
239
268
  },
240
269
  cancelOnError: true,
241
270
  );
242
- final hello = await _actions.buildHello(
243
- deviceId: _deviceId,
244
- activationId: _activationId,
245
- label: _label,
246
- companionEnabled: _enabled,
247
- paused: _paused,
248
- activeDisplayId: _activeDisplayId,
249
- );
271
+ final hello = await _actions
272
+ .buildHello(
273
+ deviceId: _deviceId,
274
+ activationId: _activationId,
275
+ label: _label,
276
+ companionEnabled: _enabled,
277
+ paused: _paused,
278
+ activeDisplayId: _activeDisplayId,
279
+ )
280
+ .timeout(const Duration(seconds: 10));
281
+ if (_socket != socket || generation != _connectionGeneration) return;
250
282
  socket.add(
251
283
  jsonEncode(<String, Object?>{'type': 'hello', 'device': hello}),
252
284
  );
285
+ _helloTimer?.cancel();
286
+ _helloTimer = Timer(const Duration(seconds: 10), () {
287
+ if (_socket != socket ||
288
+ generation != _connectionGeneration ||
289
+ _connected) {
290
+ return;
291
+ }
292
+ _errorMessage = 'Desktop companion handshake timed out.';
293
+ _handleSocketClosed(socket, generation);
294
+ });
253
295
  } catch (error) {
296
+ if (connectedSocket != null && connectedSocket == _socket) {
297
+ _socket = null;
298
+ unawaited(_closeSocket(connectedSocket));
299
+ }
300
+ if (_disposed || generation != _connectionGeneration) return;
254
301
  _connecting = false;
255
302
  _connected = false;
256
303
  _errorMessage = '$error';
257
- notifyListeners();
304
+ _notify();
258
305
  _scheduleReconnect();
259
306
  }
260
307
  }
261
308
 
262
- void _handleMessage(dynamic raw) {
309
+ Future<WebSocket> _openWebSocket(Uri uri) async {
310
+ final pending = WebSocket.connect(
311
+ uri.toString(),
312
+ headers: <String, Object>{'Cookie': _sessionCookie},
313
+ );
314
+ try {
315
+ return await pending.timeout(const Duration(seconds: 15));
316
+ } on TimeoutException {
317
+ unawaited(() async {
318
+ try {
319
+ final lateSocket = await pending;
320
+ await _closeSocket(lateSocket);
321
+ } catch (_) {}
322
+ }());
323
+ throw TimeoutException(
324
+ 'Desktop companion connection timed out.',
325
+ const Duration(seconds: 15),
326
+ );
327
+ }
328
+ }
329
+
330
+ Future<void> _closeSocket(WebSocket socket) async {
331
+ try {
332
+ await socket.close().timeout(const Duration(seconds: 2));
333
+ } catch (_) {}
334
+ }
335
+
336
+ void _handleMessage(WebSocket source, int generation, dynamic raw) {
337
+ if (_socket != source || generation != _connectionGeneration) return;
263
338
  try {
264
339
  final message = jsonDecode(raw as String);
265
340
  if (message is! Map) return;
266
341
  final type = message['type']?.toString() ?? '';
267
342
  if (type == 'hello') {
343
+ _helloTimer?.cancel();
344
+ _helloTimer = null;
268
345
  _connecting = false;
269
346
  final ok = message['ok'] == true;
270
347
  if (!ok) {
271
348
  _connected = false;
272
349
  _errorMessage =
273
350
  message['error']?.toString() ?? 'Desktop companion rejected.';
274
- notifyListeners();
275
- _handleSocketClosed();
351
+ _notify();
352
+ _handleSocketClosed(source, generation);
276
353
  return;
277
354
  }
278
355
  _connected = true;
356
+ _reconnectAttempt = 0;
279
357
  _errorMessage = null;
280
358
  final device = message['device'];
281
359
  _status = device is Map
@@ -283,18 +361,37 @@ class DesktopCompanionManager extends ChangeNotifier {
283
361
  : const <String, Object?>{};
284
362
  _activeDisplayId =
285
363
  _status['activeDisplayId']?.toString() ?? _activeDisplayId;
286
- notifyListeners();
364
+ _notify();
287
365
  return;
288
366
  }
289
367
  if (type != 'command') return;
290
- unawaited(_handleCommand(message.cast<String, Object?>()));
368
+ final commandMessage = message.cast<String, Object?>();
369
+ final command = commandMessage['command']?.toString() ?? '';
370
+ final commandId = commandMessage['id']?.toString() ?? '';
371
+ if (command != 'cancelCommand' && commandId.isNotEmpty) {
372
+ _pendingCommandIds.add(commandId);
373
+ if (command == 'executeCommand') {
374
+ _pendingShellCommandIds.add(commandId);
375
+ }
376
+ }
377
+ if (_inputCommands.contains(command)) {
378
+ final previous = _inputCommandQueue;
379
+ _inputCommandQueue = () async {
380
+ try {
381
+ await previous;
382
+ } catch (_) {}
383
+ await _handleCommand(commandMessage, source, generation);
384
+ }();
385
+ } else {
386
+ unawaited(_handleCommand(commandMessage, source, generation));
387
+ }
291
388
  } on FormatException catch (error) {
292
389
  _errorMessage = 'Ignored malformed desktop companion message: $error';
293
- notifyListeners();
390
+ _notify();
294
391
  return;
295
392
  } catch (error) {
296
393
  _errorMessage = 'Desktop companion message handling failed: $error';
297
- notifyListeners();
394
+ _notify();
298
395
  return;
299
396
  }
300
397
  }
@@ -312,7 +409,11 @@ class DesktopCompanionManager extends ChangeNotifier {
312
409
  'pressKey',
313
410
  };
314
411
 
315
- Future<void> _handleCommand(Map<String, Object?> message) async {
412
+ Future<void> _handleCommand(
413
+ Map<String, Object?> message,
414
+ WebSocket source,
415
+ int generation,
416
+ ) async {
316
417
  final id = message['id']?.toString() ?? '';
317
418
  final command = message['command']?.toString() ?? '';
318
419
  final payload = message['payload'] is Map
@@ -321,23 +422,52 @@ class DesktopCompanionManager extends ChangeNotifier {
321
422
  )
322
423
  : const <String, Object?>{};
323
424
 
425
+ if (_socket != source || generation != _connectionGeneration) {
426
+ _pendingCommandIds.remove(id);
427
+ _pendingShellCommandIds.remove(id);
428
+ _cancelledCommandIds.remove(id);
429
+ return;
430
+ }
431
+
324
432
  final isInput = _inputCommands.contains(command);
325
433
  if (isInput) _inputCommandInFlight = true;
326
434
 
327
435
  try {
328
- final response = await _dispatchCommand(command, payload);
329
- _socket?.add(
330
- jsonEncode(<String, Object?>{
436
+ if (command != 'cancelCommand' && _cancelledCommandIds.contains(id)) {
437
+ _sendCommandResult(source, generation, <String, Object?>{
331
438
  'type': 'result',
332
439
  'id': id,
333
- 'ok': true,
334
- 'payload': response,
335
- }),
336
- );
440
+ 'ok': false,
441
+ 'code': 'COMMAND_CANCELLED',
442
+ 'error': 'Desktop companion command was cancelled.',
443
+ });
444
+ return;
445
+ }
446
+ final response = await _dispatchCommand(command, payload, commandId: id);
447
+ if (command != 'cancelCommand' && _cancelledCommandIds.contains(id)) {
448
+ _sendCommandResult(source, generation, <String, Object?>{
449
+ 'type': 'result',
450
+ 'id': id,
451
+ 'ok': false,
452
+ 'code': 'COMMAND_CANCELLED',
453
+ 'error': 'Desktop companion command was cancelled.',
454
+ });
455
+ return;
456
+ }
457
+ _sendCommandResult(source, generation, <String, Object?>{
458
+ 'type': 'result',
459
+ 'id': id,
460
+ 'ok': true,
461
+ 'payload': response,
462
+ });
337
463
  // Immediately capture a fresh frame after an input action so the user
338
464
  // sees the result of their interaction without waiting for the next
339
465
  // timer tick.
340
- if (isInput && _streamTimer != null && _connected) {
466
+ if (isInput &&
467
+ _streamTimer != null &&
468
+ _connected &&
469
+ _socket == source &&
470
+ generation == _connectionGeneration) {
341
471
  unawaited(
342
472
  _captureAndSendBinaryFrame(
343
473
  _currentStreamQuality,
@@ -347,24 +477,43 @@ class DesktopCompanionManager extends ChangeNotifier {
347
477
  );
348
478
  }
349
479
  } catch (error) {
350
- _socket?.add(
351
- jsonEncode(<String, Object?>{
352
- 'type': 'result',
353
- 'id': id,
354
- 'ok': false,
355
- 'error': '$error',
356
- }),
357
- );
480
+ _sendCommandResult(source, generation, <String, Object?>{
481
+ 'type': 'result',
482
+ 'id': id,
483
+ 'ok': false,
484
+ 'error': '$error',
485
+ });
358
486
  } finally {
359
487
  if (isInput) _inputCommandInFlight = false;
488
+ _pendingCommandIds.remove(id);
489
+ _pendingShellCommandIds.remove(id);
490
+ _cancelledCommandIds.remove(id);
491
+ }
492
+ }
493
+
494
+ void _sendCommandResult(
495
+ WebSocket source,
496
+ int generation,
497
+ Map<String, Object?> message,
498
+ ) {
499
+ if (_socket != source || generation != _connectionGeneration) return;
500
+ try {
501
+ source.add(jsonEncode(message));
502
+ } catch (error) {
503
+ _errorMessage = 'Desktop companion response failed: $error';
504
+ _handleSocketClosed(source, generation);
360
505
  }
361
506
  }
362
507
 
363
508
  Future<Map<String, Object?>> _dispatchCommand(
364
509
  String command,
365
- Map<String, Object?> payload,
366
- ) async {
367
- if (_paused && command != 'getStatus' && command != 'pauseControl') {
510
+ Map<String, Object?> payload, {
511
+ required String commandId,
512
+ }) async {
513
+ if (_paused &&
514
+ command != 'getStatus' &&
515
+ command != 'pauseControl' &&
516
+ command != 'cancelCommand') {
368
517
  throw Exception('Desktop companion is paused locally.');
369
518
  }
370
519
  switch (command) {
@@ -387,23 +536,23 @@ class DesktopCompanionManager extends ChangeNotifier {
387
536
  );
388
537
  case 'click':
389
538
  return _actions.click(
390
- x: (payload['x'] as num?)?.round() ?? 0,
391
- y: (payload['y'] as num?)?.round() ?? 0,
539
+ x: _requiredCoordinate(payload, 'x'),
540
+ y: _requiredCoordinate(payload, 'y'),
392
541
  button: payload['button']?.toString() ?? 'left',
393
542
  displayId: _activeDisplayId,
394
543
  );
395
544
  case 'mouseMove':
396
545
  return _actions.mouseMove(
397
- x: (payload['x'] as num?)?.round() ?? 0,
398
- y: (payload['y'] as num?)?.round() ?? 0,
546
+ x: _requiredCoordinate(payload, 'x'),
547
+ y: _requiredCoordinate(payload, 'y'),
399
548
  displayId: _activeDisplayId,
400
549
  );
401
550
  case 'drag':
402
551
  return _actions.drag(
403
- x1: (payload['x1'] as num?)?.round() ?? 0,
404
- y1: (payload['y1'] as num?)?.round() ?? 0,
405
- x2: (payload['x2'] as num?)?.round() ?? 0,
406
- y2: (payload['y2'] as num?)?.round() ?? 0,
552
+ x1: _requiredCoordinate(payload, 'x1'),
553
+ y1: _requiredCoordinate(payload, 'y1'),
554
+ x2: _requiredCoordinate(payload, 'x2'),
555
+ y2: _requiredCoordinate(payload, 'y2'),
407
556
  durationMs: (payload['durationMs'] as num?)?.round() ?? 280,
408
557
  displayId: _activeDisplayId,
409
558
  );
@@ -433,28 +582,54 @@ class DesktopCompanionManager extends ChangeNotifier {
433
582
  'activeDisplayId': status['activeDisplayId'] ?? 'primary',
434
583
  };
435
584
  case 'selectDisplay':
436
- final displayId = payload['displayId']?.toString() ?? 'primary';
437
- _activeDisplayId = displayId;
585
+ final displayId = await _resolveDisplaySelection(
586
+ payload['displayId']?.toString() ?? '',
587
+ );
438
588
  final prefs = await SharedPreferences.getInstance();
439
- await prefs.setString(desktopCompanionActiveDisplayPrefsKey, displayId);
589
+ final persisted = await prefs.setString(
590
+ desktopCompanionActiveDisplayPrefsKey,
591
+ displayId,
592
+ );
593
+ if (!persisted) {
594
+ throw StateError('Unable to persist the selected desktop display.');
595
+ }
596
+ _activeDisplayId = displayId;
440
597
  _status = <String, Object?>{..._status, 'activeDisplayId': displayId};
441
- // TODO: Apply platform-specific active display switching when available.
442
- notifyListeners();
598
+ _notify();
443
599
  return <String, Object?>{'success': true, 'activeDisplayId': displayId};
444
600
  case 'getTree':
445
601
  return _actions.getTree();
446
602
  case 'pauseControl':
447
603
  final paused = payload['paused'] != false;
448
604
  _paused = paused;
449
- notifyListeners();
605
+ _notify();
450
606
  return <String, Object?>{'success': true, 'paused': _paused};
451
607
  case 'executeCommand':
452
608
  return _actions.executeShellCommand(
609
+ commandId: commandId,
453
610
  command: payload['command']?.toString() ?? '',
454
611
  cwd: payload['cwd']?.toString(),
455
612
  timeoutMs: (payload['timeout'] as num?)?.toInt(),
456
613
  stdinInput: payload['stdin_input']?.toString(),
614
+ requestedPty: payload['pty'] == true,
615
+ inputs: payload['inputs'] is List
616
+ ? (payload['inputs'] as List)
617
+ .map((value) => value.toString())
618
+ .toList(growable: false)
619
+ : const <String>[],
457
620
  );
621
+ case 'cancelCommand':
622
+ final targetId = payload['commandId']?.toString() ?? '';
623
+ if (targetId.isNotEmpty && _pendingCommandIds.contains(targetId)) {
624
+ _cancelledCommandIds.add(targetId);
625
+ }
626
+ if (_pendingShellCommandIds.contains(targetId)) {
627
+ return _actions.cancelShellCommand(targetId);
628
+ }
629
+ return <String, Object?>{
630
+ 'success': targetId.isNotEmpty,
631
+ 'cancelled': _cancelledCommandIds.contains(targetId),
632
+ };
458
633
  case 'ping':
459
634
  return <String, Object?>{'pong': true};
460
635
  default:
@@ -462,49 +637,86 @@ class DesktopCompanionManager extends ChangeNotifier {
462
637
  }
463
638
  }
464
639
 
465
- void _handleSocketClosed() {
640
+ int _requiredCoordinate(Map<String, Object?> payload, String key) {
641
+ final value = payload[key];
642
+ if (value is! num || !value.isFinite) {
643
+ throw FormatException('$key must be a finite number.');
644
+ }
645
+ return value.round();
646
+ }
647
+
648
+ void _handleSocketClosed(WebSocket source, int generation) {
649
+ if (_socket != source || generation != _connectionGeneration) return;
650
+ _connectionGeneration++;
651
+ _helloTimer?.cancel();
652
+ _helloTimer = null;
466
653
  _stopStreaming();
467
654
  _socket = null;
468
655
  _connecting = false;
469
656
  _connected = false;
470
- notifyListeners();
657
+ _cancelPendingCommands();
658
+ unawaited(_closeSocket(source));
659
+ if (_disposed) return;
660
+ _notify();
471
661
  _scheduleReconnect();
472
662
  }
473
663
 
664
+ void _cancelPendingCommands() {
665
+ for (final commandId in _pendingCommandIds.toList(growable: false)) {
666
+ _cancelledCommandIds.add(commandId);
667
+ if (_pendingShellCommandIds.contains(commandId)) {
668
+ unawaited(_actions.cancelShellCommand(commandId));
669
+ }
670
+ }
671
+ }
672
+
474
673
  @override
475
674
  void dispose() {
675
+ _disposed = true;
676
+ _connectionGeneration++;
476
677
  _reconnectTimer?.cancel();
477
678
  _reconnectTimer = null;
478
679
  _connectionWatchdogTimer?.cancel();
479
680
  _connectionWatchdogTimer = null;
681
+ _helloTimer?.cancel();
682
+ _helloTimer = null;
480
683
  _stopStreaming();
481
684
  _connecting = false;
482
685
  _connected = false;
483
686
  _enabled = false;
687
+ _cancelPendingCommands();
484
688
  final socket = _socket;
485
689
  _socket = null;
486
690
  if (socket != null) {
487
- try {
488
- socket.close();
489
- } catch (_) {}
691
+ unawaited(_closeSocket(socket));
490
692
  }
491
693
  super.dispose();
492
694
  }
493
695
 
494
696
  void _scheduleReconnect() {
495
- if (!_enabled || !_authenticated || _sessionCookie.isEmpty) return;
697
+ if (_disposed || !_enabled || !_authenticated || _sessionCookie.isEmpty) {
698
+ return;
699
+ }
496
700
  _ensureConnectionWatchdog();
497
701
  _reconnectTimer?.cancel();
498
- _reconnectTimer = Timer(const Duration(seconds: 5), () {
499
- unawaited(_ensureConnected());
500
- });
702
+ final exponentialMs = min(60000, 1000 * (1 << min(_reconnectAttempt, 6)));
703
+ _reconnectAttempt++;
704
+ final jitterMs = Random().nextInt(max(1, exponentialMs ~/ 4));
705
+ _reconnectTimer = Timer(
706
+ Duration(milliseconds: exponentialMs + jitterMs),
707
+ () {
708
+ unawaited(_ensureConnected());
709
+ },
710
+ );
501
711
  }
502
712
 
503
713
  void _ensureConnectionWatchdog() {
504
- if (!_enabled || !_authenticated || _sessionCookie.isEmpty) return;
714
+ if (_disposed || !_enabled || !_authenticated || _sessionCookie.isEmpty) {
715
+ return;
716
+ }
505
717
  if (_connectionWatchdogTimer != null) return;
506
718
  _connectionWatchdogTimer = Timer.periodic(const Duration(seconds: 30), (_) {
507
- if (!_enabled || !_authenticated || _sessionCookie.isEmpty) {
719
+ if (_disposed || !_enabled || !_authenticated || _sessionCookie.isEmpty) {
508
720
  _connectionWatchdogTimer?.cancel();
509
721
  _connectionWatchdogTimer = null;
510
722
  return;
@@ -530,14 +742,16 @@ class DesktopCompanionManager extends ChangeNotifier {
530
742
  Future<Map<String, Object?>> _startStreaming(
531
743
  Map<String, Object?> payload,
532
744
  ) async {
533
- _streamTimer?.cancel();
534
- final generation = ++_streamGeneration;
535
745
  final fps = ((payload['fps'] as num?)?.round() ?? 15).clamp(1, 20);
536
746
  final quality = ((payload['quality'] as num?)?.round() ?? 80).clamp(30, 95);
537
747
  final displayId = payload['displayId']?.toString().trim();
748
+ var selectedDisplayId = _activeDisplayId;
538
749
  if (displayId != null && displayId.isNotEmpty) {
539
- _activeDisplayId = displayId;
750
+ selectedDisplayId = await _resolveDisplaySelection(displayId);
540
751
  }
752
+ _streamTimer?.cancel();
753
+ final generation = ++_streamGeneration;
754
+ _activeDisplayId = selectedDisplayId;
541
755
  final interval = Duration(milliseconds: max(1, (1000 / fps).floor()));
542
756
  _frameSeq = 0;
543
757
  _currentStreamQuality = quality;
@@ -553,6 +767,19 @@ class DesktopCompanionManager extends ChangeNotifier {
553
767
  };
554
768
  }
555
769
 
770
+ Future<String> _resolveDisplaySelection(String requested) async {
771
+ final status = await _actions.getStatus(
772
+ label: _label,
773
+ paused: _paused,
774
+ activeDisplayId: _activeDisplayId,
775
+ );
776
+ return resolveDesktopDisplaySelection(
777
+ status['displays'],
778
+ requested,
779
+ activeDisplayId: status['activeDisplayId']?.toString(),
780
+ );
781
+ }
782
+
556
783
  Map<String, Object?> _stopStreaming() {
557
784
  _streamTimer?.cancel();
558
785
  _streamTimer = null;
@@ -601,7 +828,7 @@ class DesktopCompanionManager extends ChangeNotifier {
601
828
  socket.add(frame);
602
829
  } catch (error) {
603
830
  _errorMessage = 'Desktop stream capture failed: $error';
604
- notifyListeners();
831
+ _notify();
605
832
  } finally {
606
833
  _streamCaptureInFlight = false;
607
834
  }
@@ -671,7 +898,13 @@ class DesktopCompanionManager extends ChangeNotifier {
671
898
  Uri _desktopWsUri(String backendUrl) {
672
899
  final base = Uri.parse(backendUrl);
673
900
  final scheme = base.scheme == 'https' ? 'wss' : 'ws';
674
- return base.replace(scheme: scheme, path: '/api/desktop/ws', query: '');
901
+ final basePath = base.path.replaceFirst(RegExp(r'/+$'), '');
902
+ return base.replace(
903
+ scheme: scheme,
904
+ path: '$basePath/api/desktop/ws',
905
+ query: '',
906
+ fragment: '',
907
+ );
675
908
  }
676
909
 
677
910
  String _defaultLabel() {