neoagent 3.2.1-beta.0 → 3.2.1-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/docs/agent-run-lifecycle.md +10 -0
  2. package/extensions/chrome-browser/background.mjs +318 -88
  3. package/extensions/chrome-browser/http.mjs +136 -0
  4. package/extensions/chrome-browser/protocol.mjs +654 -90
  5. package/flutter_app/lib/main_chat.dart +118 -739
  6. package/flutter_app/lib/main_controller.dart +111 -20
  7. package/flutter_app/lib/main_integrations.dart +607 -8
  8. package/flutter_app/lib/main_models.dart +3 -0
  9. package/flutter_app/lib/main_operations.dart +334 -321
  10. package/flutter_app/lib/main_security.dart +266 -112
  11. package/flutter_app/lib/main_settings.dart +4 -3
  12. package/flutter_app/lib/main_shared.dart +14 -11
  13. package/flutter_app/lib/src/backend_client.dart +78 -0
  14. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  15. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  16. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  17. package/landing/index.html +3 -1
  18. package/lib/manager.js +106 -89
  19. package/lib/schema_migrations.js +145 -13
  20. package/package.json +30 -15
  21. package/runtime/paths.js +49 -5
  22. package/server/db/database.js +4 -4
  23. package/server/guest-agent.cli.package.json +13 -0
  24. package/server/guest_agent.js +85 -40
  25. package/server/http/middleware.js +24 -0
  26. package/server/http/routes.js +11 -6
  27. package/server/public/.last_build_id +1 -1
  28. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  29. package/server/public/flutter_bootstrap.js +2 -2
  30. package/server/public/main.dart.js +73083 -72209
  31. package/server/routes/admin.js +1 -1
  32. package/server/routes/agents.js +35 -2
  33. package/server/routes/android.js +30 -34
  34. package/server/routes/browser.js +23 -15
  35. package/server/routes/desktop.js +18 -1
  36. package/server/routes/integrations.js +107 -1
  37. package/server/routes/memory.js +1 -0
  38. package/server/routes/settings.js +16 -5
  39. package/server/routes/social_reach.js +12 -3
  40. package/server/routes/social_video.js +4 -0
  41. package/server/services/agents/manager.js +1 -1
  42. package/server/services/ai/capabilityHealth.js +62 -96
  43. package/server/services/ai/compaction.js +7 -2
  44. package/server/services/ai/history.js +45 -6
  45. package/server/services/ai/integrated_tools/http_request.js +8 -0
  46. package/server/services/ai/loop/agent_engine_core.js +496 -166
  47. package/server/services/ai/loop/blank_recovery.js +5 -4
  48. package/server/services/ai/loop/callbacks.js +1 -0
  49. package/server/services/ai/loop/completion_judge.js +121 -5
  50. package/server/services/ai/loop/conversation_loop.js +620 -340
  51. package/server/services/ai/loop/lifecycle.js +108 -0
  52. package/server/services/ai/loop/messaging_delivery.js +129 -57
  53. package/server/services/ai/loop/model_call_guard.js +91 -0
  54. package/server/services/ai/loop/model_io.js +48 -56
  55. package/server/services/ai/loop/progress_classification.js +2 -0
  56. package/server/services/ai/loop/tool_dispatch.js +28 -8
  57. package/server/services/ai/loopPolicy.js +48 -21
  58. package/server/services/ai/messagingFallback.js +17 -17
  59. package/server/services/ai/model_discovery.js +227 -0
  60. package/server/services/ai/model_failure_cache.js +108 -0
  61. package/server/services/ai/model_identity.js +71 -0
  62. package/server/services/ai/models.js +68 -163
  63. package/server/services/ai/providerRetry.js +17 -59
  64. package/server/services/ai/provider_selector.js +166 -0
  65. package/server/services/ai/providers/anthropic.js +4 -4
  66. package/server/services/ai/providers/claudeCode.js +21 -33
  67. package/server/services/ai/providers/githubCopilot.js +41 -20
  68. package/server/services/ai/providers/google.js +135 -97
  69. package/server/services/ai/providers/grok.js +6 -5
  70. package/server/services/ai/providers/grokOauth.js +19 -27
  71. package/server/services/ai/providers/nvidia.js +12 -7
  72. package/server/services/ai/providers/ollama.js +114 -86
  73. package/server/services/ai/providers/ollama_stream.js +142 -0
  74. package/server/services/ai/providers/openai.js +41 -7
  75. package/server/services/ai/providers/openaiCodex.js +13 -4
  76. package/server/services/ai/providers/openrouter.js +31 -9
  77. package/server/services/ai/providers/provider_error.js +36 -0
  78. package/server/services/ai/settings.js +26 -2
  79. package/server/services/ai/systemPrompt.js +19 -12
  80. package/server/services/ai/taskAnalysis.js +58 -10
  81. package/server/services/ai/terminal_reply.js +18 -0
  82. package/server/services/ai/toolEvidence.js +350 -29
  83. package/server/services/ai/tools.js +190 -111
  84. package/server/services/android/controller.js +770 -237
  85. package/server/services/android/process.js +140 -0
  86. package/server/services/android/sdk_download.js +143 -0
  87. package/server/services/android/uia.js +6 -5
  88. package/server/services/artifacts/store.js +24 -0
  89. package/server/services/browser/controller.js +843 -385
  90. package/server/services/browser/extension/gateway.js +40 -16
  91. package/server/services/browser/extension/protocol.js +15 -1
  92. package/server/services/browser/extension/provider.js +71 -47
  93. package/server/services/browser/extension/registry.js +155 -34
  94. package/server/services/cli/executor.js +62 -9
  95. package/server/services/credentials/bitwarden_cli.js +322 -0
  96. package/server/services/credentials/broker.js +594 -0
  97. package/server/services/desktop/gateway.js +41 -4
  98. package/server/services/desktop/protocol.js +3 -0
  99. package/server/services/desktop/provider.js +39 -42
  100. package/server/services/desktop/registry.js +137 -52
  101. package/server/services/integrations/bitwarden/constants.js +14 -0
  102. package/server/services/integrations/bitwarden/provider.js +197 -0
  103. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  104. package/server/services/integrations/figma/provider.js +78 -12
  105. package/server/services/integrations/github/common.js +11 -6
  106. package/server/services/integrations/github/provider.js +52 -53
  107. package/server/services/integrations/google/provider.js +55 -19
  108. package/server/services/integrations/home_assistant/network.js +17 -20
  109. package/server/services/integrations/home_assistant/provider.js +7 -5
  110. package/server/services/integrations/home_assistant/tools.js +17 -5
  111. package/server/services/integrations/http.js +51 -0
  112. package/server/services/integrations/manager.js +159 -53
  113. package/server/services/integrations/microsoft/provider.js +80 -13
  114. package/server/services/integrations/neoarchive/provider.js +55 -29
  115. package/server/services/integrations/neorecall/client.js +17 -10
  116. package/server/services/integrations/neorecall/provider.js +20 -11
  117. package/server/services/integrations/notion/provider.js +16 -13
  118. package/server/services/integrations/oauth_provider.js +115 -51
  119. package/server/services/integrations/registry.js +2 -0
  120. package/server/services/integrations/slack/provider.js +98 -9
  121. package/server/services/integrations/spotify/provider.js +67 -71
  122. package/server/services/integrations/trello/provider.js +21 -7
  123. package/server/services/integrations/weather/provider.js +18 -12
  124. package/server/services/integrations/whatsapp/provider.js +76 -16
  125. package/server/services/manager.js +110 -1
  126. package/server/services/memory/embedding_index.js +20 -8
  127. package/server/services/memory/embeddings.js +151 -90
  128. package/server/services/memory/ingestion.js +50 -9
  129. package/server/services/memory/ingestion_documents.js +13 -3
  130. package/server/services/memory/manager.js +52 -19
  131. package/server/services/messaging/automation.js +85 -10
  132. package/server/services/messaging/formatting_guides.js +7 -4
  133. package/server/services/messaging/http_platforms.js +33 -13
  134. package/server/services/messaging/inbound_queue.js +78 -24
  135. package/server/services/messaging/inbound_store.js +224 -0
  136. package/server/services/messaging/manager.js +326 -51
  137. package/server/services/messaging/typing_keepalive.js +5 -2
  138. package/server/services/messaging/whatsapp.js +22 -14
  139. package/server/services/network/http.js +210 -0
  140. package/server/services/network/safe_request.js +307 -0
  141. package/server/services/runtime/backends/local-vm.js +227 -67
  142. package/server/services/runtime/docker-vm-manager.js +9 -0
  143. package/server/services/runtime/guest_bootstrap.js +30 -4
  144. package/server/services/runtime/guest_image.js +43 -12
  145. package/server/services/runtime/manager.js +77 -23
  146. package/server/services/runtime/validation.js +7 -6
  147. package/server/services/security/tool_categories.js +6 -0
  148. package/server/services/social_reach/channels/github.js +10 -4
  149. package/server/services/social_reach/channels/reddit.js +4 -4
  150. package/server/services/social_reach/channels/rss.js +2 -2
  151. package/server/services/social_reach/channels/social_video.js +12 -7
  152. package/server/services/social_reach/channels/v2ex.js +21 -8
  153. package/server/services/social_reach/channels/x.js +2 -2
  154. package/server/services/social_reach/channels/xueqiu.js +5 -5
  155. package/server/services/social_reach/service.js +9 -6
  156. package/server/services/social_reach/utils.js +65 -14
  157. package/server/services/social_video/service.js +160 -50
  158. package/server/services/tasks/integration_runtime.js +18 -8
  159. package/server/services/tasks/runtime.js +39 -4
  160. package/server/services/voice/agentBridge.js +17 -4
  161. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  162. package/server/services/voice/liveSession.js +31 -0
  163. package/server/services/voice/message.js +1 -1
  164. package/server/services/voice/openaiSpeech.js +33 -8
  165. package/server/services/voice/providers.js +233 -151
  166. package/server/services/voice/runtime.js +2 -2
  167. package/server/services/voice/runtimeManager.js +118 -20
  168. package/server/services/voice/turnRunner.js +6 -0
  169. package/server/services/wearable/firmware_manifest.js +51 -13
  170. package/server/services/wearable/service.js +1 -0
  171. package/server/utils/abort.js +96 -0
  172. package/server/utils/cloud-security.js +110 -3
  173. package/server/utils/files.js +31 -0
  174. package/server/utils/image_payload.js +95 -0
  175. package/server/utils/retry.js +107 -0
@@ -1,3 +1,5 @@
1
+ 'use strict';
2
+
1
3
  const DESKTOP_COMPANION_WS_PATH = '/api/desktop/ws';
2
4
 
3
5
  const DESKTOP_COMMANDS = Object.freeze({
@@ -17,6 +19,7 @@ const DESKTOP_COMMANDS = Object.freeze({
17
19
  GET_TREE: 'getTree',
18
20
  PAUSE_CONTROL: 'pauseControl',
19
21
  EXECUTE_COMMAND: 'executeCommand',
22
+ CANCEL_COMMAND: 'cancelCommand',
20
23
  PING: 'ping',
21
24
  MOUSE_MOVE: 'mouseMove',
22
25
  });
@@ -1,28 +1,18 @@
1
+ 'use strict';
2
+
1
3
  const fs = require('fs');
2
4
  const path = require('path');
3
5
  const { DATA_DIR } = require('../../../runtime/paths');
6
+ const { writeBufferAtomic } = require('../../utils/files');
7
+ const { decodeBase64Image } = require('../../utils/image_payload');
4
8
  const {
5
9
  DESKTOP_COMMANDS,
6
- DesktopCompanionSelectionError,
7
10
  DesktopCompanionUnavailableError,
8
11
  } = require('./protocol');
9
12
 
10
13
  const SCREENSHOTS_DIR = path.join(DATA_DIR, 'screenshots');
11
14
  if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
12
15
 
13
- function extractBase64Image(value) {
14
- const text = String(value || '');
15
- if (!text) return null;
16
- const match = text.match(/^data:image\/(?:png|jpeg|jpg);base64,(.+)$/i);
17
- return match ? match[1] : text;
18
- }
19
-
20
- function guessExtension(result = {}) {
21
- const mime = String(result.contentType || result.mimeType || 'image/png').toLowerCase();
22
- if (mime.includes('jpeg') || mime.includes('jpg')) return 'jpg';
23
- return 'png';
24
- }
25
-
26
16
  class DesktopProvider {
27
17
  constructor(options = {}) {
28
18
  this.registry = options.registry;
@@ -36,24 +26,21 @@ class DesktopProvider {
36
26
  }
37
27
  }
38
28
 
39
- _writeScreenshotArtifact(base64, result = {}) {
40
- const buffer = Buffer.from(base64, 'base64');
41
- const extension = guessExtension(result);
42
- const contentType = result.contentType || (extension === 'jpg' ? 'image/jpeg' : 'image/png');
29
+ async _writeScreenshotArtifact(image, result = {}, options = {}) {
43
30
  if (this.artifactStore && this.userId != null) {
44
- const artifact = this.artifactStore.allocateFile(this.userId, {
31
+ const artifact = await this.artifactStore.createBufferArtifact(this.userId, {
45
32
  kind: 'desktop-screenshot',
46
33
  backend: 'desktop-companion',
47
- extension,
48
- contentType,
34
+ extension: image.extension,
35
+ contentType: image.contentType,
49
36
  filenameBase: 'desktop-companion-screenshot',
37
+ content: image.buffer,
38
+ signal: options.signal,
50
39
  metadata: {
51
40
  deviceId: result.device?.deviceId || null,
52
41
  displayId: result.displayId || result.device?.activeDisplayId || null,
53
42
  },
54
43
  });
55
- fs.writeFileSync(artifact.storagePath, buffer);
56
- this.artifactStore.finalizeFile(artifact.artifactId, artifact.storagePath);
57
44
  return {
58
45
  screenshotPath: artifact.url,
59
46
  artifactId: artifact.artifactId,
@@ -62,9 +49,9 @@ class DesktopProvider {
62
49
  };
63
50
  }
64
51
 
65
- const filename = `desktop_${Date.now()}_${Math.random().toString(16).slice(2)}.${extension}`;
52
+ const filename = `desktop_${Date.now()}_${Math.random().toString(16).slice(2)}.${image.extension}`;
66
53
  const fullPath = path.join(SCREENSHOTS_DIR, filename);
67
- fs.writeFileSync(fullPath, buffer);
54
+ await writeBufferAtomic(fullPath, image.buffer, { signal: options.signal });
68
55
  return {
69
56
  screenshotPath: `/screenshots/${filename}`,
70
57
  artifactId: null,
@@ -73,13 +60,14 @@ class DesktopProvider {
73
60
  };
74
61
  }
75
62
 
76
- _materialize(result) {
63
+ async _materialize(result, options = {}) {
77
64
  if (!result || typeof result !== 'object') return result;
78
65
  const raw = result.screenshotDataUrl || result.screenshotData || result.screenshotBase64;
79
66
  if (!raw) return result;
80
- const base64 = extractBase64Image(raw);
81
- if (!base64) return result;
82
- const screenshot = this._writeScreenshotArtifact(base64, result);
67
+ const image = decodeBase64Image(raw, {
68
+ allowedTypes: ['image/png', 'image/jpeg'],
69
+ });
70
+ const screenshot = await this._writeScreenshotArtifact(image, result, options);
83
71
  const next = { ...result, ...screenshot };
84
72
  delete next.screenshotDataUrl;
85
73
  delete next.screenshotData;
@@ -89,16 +77,19 @@ class DesktopProvider {
89
77
 
90
78
  async _dispatch(command, payload = {}, options = {}) {
91
79
  this._assertReady();
92
- try {
93
- return this._materialize(
94
- await this.registry.dispatch(this.userId, payload.deviceId || null, command, payload, options),
95
- );
96
- } catch (error) {
97
- if (error instanceof DesktopCompanionSelectionError || error instanceof DesktopCompanionUnavailableError) {
98
- throw error;
99
- }
100
- throw error;
101
- }
80
+ const safePayload = { ...(payload || {}) };
81
+ const signal = options.signal || safePayload.signal || null;
82
+ const timeoutMs = options.timeoutMs ?? safePayload.timeoutMs;
83
+ delete safePayload.signal;
84
+ delete safePayload.timeoutMs;
85
+ const result = await this.registry.dispatch(
86
+ this.userId,
87
+ safePayload.deviceId || null,
88
+ command,
89
+ safePayload,
90
+ { ...options, signal, timeoutMs },
91
+ );
92
+ return this._materialize(result, { signal });
102
93
  }
103
94
 
104
95
  getStatus() {
@@ -121,9 +112,9 @@ class DesktopProvider {
121
112
  return this.registry.revoke(this.userId, deviceId);
122
113
  }
123
114
 
124
- pauseDevice(deviceId, paused = true) {
115
+ pauseDevice(deviceId, paused = true, options = {}) {
125
116
  this._assertReady();
126
- return this.registry.pause(this.userId, deviceId, paused);
117
+ return this.registry.pause(this.userId, deviceId, paused, options);
127
118
  }
128
119
 
129
120
  screenshot(options = {}) {
@@ -137,7 +128,7 @@ class DesktopProvider {
137
128
 
138
129
  stopStream(options = {}) {
139
130
  this._assertReady();
140
- return this.registry.stopStream(this.userId, options.deviceId || null);
131
+ return this.registry.stopStream(this.userId, options.deviceId || null, options);
141
132
  }
142
133
 
143
134
  observe(options = {}) {
@@ -192,6 +183,12 @@ class DesktopProvider {
192
183
  stdin_input: options.stdinInput || null,
193
184
  pty: options.pty === true,
194
185
  inputs: options.inputs || [],
186
+ signal: options.signal,
187
+ }, {
188
+ signal: options.signal,
189
+ timeoutMs: Number(options.timeout || 0) > 0
190
+ ? Number(options.timeout) + 10_000
191
+ : undefined,
195
192
  });
196
193
  }
197
194
  }
@@ -1,5 +1,8 @@
1
+ 'use strict';
2
+
1
3
  const crypto = require('crypto');
2
4
  const db = require('../../db/database');
5
+ const { createAbortError } = require('../../utils/abort');
3
6
  const {
4
7
  DESKTOP_COMMANDS,
5
8
  FRAME_TYPE_VIDEO,
@@ -66,6 +69,7 @@ class DesktopCompanionRegistry {
66
69
  || DEFAULT_PRESENCE_TOUCH_INTERVAL_MS,
67
70
  );
68
71
  this.connectionsByUser = new Map();
72
+ this.closed = false;
69
73
  }
70
74
 
71
75
  _getUserMap(userId, create = false) {
@@ -199,6 +203,9 @@ class DesktopCompanionRegistry {
199
203
  }
200
204
 
201
205
  registerConnection({ userId, sessionId, ws, hello, remoteAddress = null, userAgent = null }) {
206
+ if (this.closed) {
207
+ throw new DesktopCompanionUnavailableError('Desktop companion registry is shutting down.');
208
+ }
202
209
  const record = this._upsertDeviceRecord(userId, hello, sessionId);
203
210
  const userMap = this._getUserMap(userId, true);
204
211
  const existing = userMap.get(record.deviceId);
@@ -249,11 +256,15 @@ class DesktopCompanionRegistry {
249
256
  // Only mark offline in the DB when this connection is still the active owner.
250
257
  // If a newer connection has already taken over (reconnect race), its
251
258
  // _upsertDeviceRecord already wrote status='online' and we must not clobber it.
252
- this.db.prepare(
253
- `UPDATE desktop_companion_devices
254
- SET status = 'offline', updated_at = datetime('now')
255
- WHERE user_id = ? AND device_id = ?`
256
- ).run(connection.userId, connection.deviceId);
259
+ try {
260
+ this.db.prepare(
261
+ `UPDATE desktop_companion_devices
262
+ SET status = 'offline', updated_at = datetime('now')
263
+ WHERE user_id = ? AND device_id = ?`
264
+ ).run(connection.userId, connection.deviceId);
265
+ } catch (error) {
266
+ console.warn('[DesktopCompanion] Failed to record disconnect:', error?.message);
267
+ }
257
268
  }
258
269
  }
259
270
 
@@ -300,18 +311,22 @@ class DesktopCompanionRegistry {
300
311
  const userMap = this._getUserMap(userId);
301
312
  const connection = userMap?.get(String(deviceId));
302
313
  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);
314
+ try {
315
+ this.db.prepare(
316
+ `UPDATE desktop_companion_devices
317
+ SET status = 'online',
318
+ last_seen_at = datetime('now'),
319
+ updated_at = datetime('now')
320
+ WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL`
321
+ ).run(userId, deviceId);
322
+ } catch (error) {
323
+ console.warn('[DesktopCompanion] Failed to update presence:', error?.message);
324
+ }
310
325
  }
311
326
 
312
327
  isConnected(userId) {
313
328
  const userMap = this._getUserMap(userId);
314
- return userMap != null && userMap.size > 0;
329
+ return userMap != null && Array.from(userMap.values()).some((connection) => connection.isOpen());
315
330
  }
316
331
 
317
332
  getConnection(userId, deviceId) {
@@ -394,15 +409,19 @@ class DesktopCompanionRegistry {
394
409
  throw new DesktopCompanionUnavailableError();
395
410
  }
396
411
  const result = await connection.sendCommand(command, payload, options);
397
- this.touchConnection(userId, device.deviceId, {
398
- label: result?.device?.label,
399
- paused: result?.paused === true,
400
- activeDisplayId: result?.activeDisplayId || result?.device?.activeDisplayId,
401
- permissions: result?.permissions,
402
- capabilities: result?.capabilities,
403
- displays: result?.displays || result?.device?.displays,
404
- metadata: result?.device?.metadata,
405
- });
412
+ try {
413
+ this.touchConnection(userId, device.deviceId, {
414
+ label: result?.device?.label,
415
+ paused: typeof result?.paused === 'boolean' ? result.paused : null,
416
+ activeDisplayId: result?.activeDisplayId || result?.device?.activeDisplayId,
417
+ permissions: result?.permissions,
418
+ capabilities: result?.capabilities,
419
+ displays: result?.displays || result?.device?.displays,
420
+ metadata: result?.device?.metadata,
421
+ });
422
+ } catch (error) {
423
+ console.warn('[DesktopCompanion] Failed to record command result:', error?.message);
424
+ }
406
425
  return {
407
426
  ...result,
408
427
  device: this.getDeviceRecordByDeviceId(userId, device.deviceId),
@@ -429,13 +448,13 @@ class DesktopCompanionRegistry {
429
448
  };
430
449
  }
431
450
 
432
- async stopStream(userId, deviceId) {
451
+ async stopStream(userId, deviceId, options = {}) {
433
452
  const device = this.resolveDevice(userId, deviceId);
434
453
  const connection = this.getConnection(userId, device.deviceId);
435
454
  if (!connection || !connection.isOpen()) {
436
455
  throw new DesktopCompanionUnavailableError();
437
456
  }
438
- const result = await connection.sendCommand(DESKTOP_COMMANDS.STREAM_STOP, {});
457
+ const result = await connection.sendCommand(DESKTOP_COMMANDS.STREAM_STOP, {}, options);
439
458
  connection._streaming = false;
440
459
  return {
441
460
  ...result,
@@ -489,7 +508,7 @@ class DesktopCompanionRegistry {
489
508
  return { success: true, deviceId: normalizedDeviceId };
490
509
  }
491
510
 
492
- pause(userId, deviceId, paused = true) {
511
+ async pause(userId, deviceId, paused = true, options = {}) {
493
512
  const normalizedDeviceId = String(deviceId || '').trim();
494
513
  if (!normalizedDeviceId) {
495
514
  throw new DesktopCompanionUnavailableError();
@@ -500,27 +519,40 @@ class DesktopCompanionRegistry {
500
519
  if (!existing) {
501
520
  throw new DesktopCompanionUnavailableError();
502
521
  }
503
- this.db.prepare(
504
- `UPDATE desktop_companion_devices
505
- SET paused = ?, updated_at = datetime('now')
506
- WHERE user_id = ? AND device_id = ?`
507
- ).run(paused ? 1 : 0, userId, normalizedDeviceId);
508
522
  const connection = this.getConnection(userId, normalizedDeviceId);
509
- if (connection) {
510
- void connection.sendCommand('pauseControl', { paused }).catch(() => {});
523
+ if (!connection?.isOpen()) {
524
+ throw new DesktopCompanionUnavailableError();
525
+ }
526
+ const result = await connection.sendCommand(
527
+ DESKTOP_COMMANDS.PAUSE_CONTROL,
528
+ { paused },
529
+ options,
530
+ );
531
+ if (result?.success !== false) {
532
+ try {
533
+ this.db.prepare(
534
+ `UPDATE desktop_companion_devices
535
+ SET paused = ?, updated_at = datetime('now')
536
+ WHERE user_id = ? AND device_id = ?`
537
+ ).run(paused ? 1 : 0, userId, normalizedDeviceId);
538
+ } catch (error) {
539
+ console.warn('[DesktopCompanion] Failed to record pause state:', error?.message);
540
+ }
511
541
  }
512
542
  return {
513
- success: true,
543
+ ...result,
544
+ success: result?.success !== false,
514
545
  deviceId: normalizedDeviceId,
515
546
  paused,
516
547
  };
517
548
  }
518
549
 
519
550
  closeAll() {
520
- for (const userMap of this.connectionsByUser.values()) {
521
- for (const connection of userMap.values()) {
522
- connection.close('server shutdown');
523
- }
551
+ this.closed = true;
552
+ const connections = Array.from(this.connectionsByUser.values())
553
+ .flatMap((userMap) => Array.from(userMap.values()));
554
+ for (const connection of connections) {
555
+ connection.close('server shutdown');
524
556
  }
525
557
  this.connectionsByUser.clear();
526
558
  }
@@ -555,24 +587,37 @@ class DesktopCompanionConnection {
555
587
  this.lastPongAt = Date.now();
556
588
  this.lastPresenceTouchAt = 0;
557
589
  this.heartbeatTimer = null;
590
+ this.closed = false;
558
591
 
559
- ws.on('message', (data) => this._handleMessage(data));
592
+ ws.on('message', (data) => {
593
+ try {
594
+ this._handleMessage(data);
595
+ } catch (error) {
596
+ try { ws.terminate(); } catch {}
597
+ this._closePending(error);
598
+ }
599
+ });
560
600
  ws.on('pong', () => {
561
601
  this.lastPongAt = Date.now();
562
602
  this.touchPresence();
563
603
  });
564
604
  ws.on('close', () => this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.')));
565
- ws.on('error', (error) => this._closePending(error));
605
+ ws.on('error', (error) => {
606
+ try { ws.terminate(); } catch {}
607
+ this._closePending(error);
608
+ });
566
609
  this._startHeartbeat();
567
610
  }
568
611
 
569
612
  isOpen() {
570
- return this.ws && this.ws.readyState === 1;
613
+ return !this.closed && this.ws && this.ws.readyState === 1;
571
614
  }
572
615
 
573
616
  close(reason) {
617
+ const wasOpen = this.isOpen();
618
+ this.closed = true;
574
619
  try {
575
- if (this.isOpen()) {
620
+ if (wasOpen) {
576
621
  this.ws.close(1000, String(reason || 'closing').slice(0, 120));
577
622
  }
578
623
  } catch {}
@@ -602,19 +647,51 @@ class DesktopCompanionConnection {
602
647
  return Promise.reject(new DesktopCompanionUnavailableError());
603
648
  }
604
649
  const id = crypto.randomUUID();
605
- const timeoutMs = Number(options.timeoutMs || this.timeoutMs);
650
+ const requestedTimeout = Number(options.timeoutMs || this.timeoutMs);
651
+ const timeoutMs = Number.isFinite(requestedTimeout)
652
+ ? Math.min(30 * 60 * 1000, Math.max(100, requestedTimeout))
653
+ : DEFAULT_COMMAND_TIMEOUT_MS;
606
654
  const message = createDesktopCommandMessage(id, command, payload);
607
655
  return new Promise((resolve, reject) => {
608
- const timer = setTimeout(() => {
656
+ const signal = options.signal || null;
657
+ const sendCancellation = () => {
658
+ if (!this.isOpen() || command === DESKTOP_COMMANDS.CANCEL_COMMAND) return;
659
+ try {
660
+ this.ws.send(JSON.stringify(createDesktopCommandMessage(
661
+ crypto.randomUUID(),
662
+ DESKTOP_COMMANDS.CANCEL_COMMAND,
663
+ { commandId: id },
664
+ )));
665
+ } catch {}
666
+ };
667
+ const cleanup = () => {
668
+ clearTimeout(timer);
669
+ signal?.removeEventListener('abort', onAbort);
609
670
  this.pending.delete(id);
610
- reject(new Error(`Desktop companion command timed out: ${command}`));
671
+ };
672
+ const onAbort = () => {
673
+ cleanup();
674
+ sendCancellation();
675
+ reject(createAbortError(signal, `Desktop companion command aborted: ${command}`));
676
+ };
677
+ const timer = setTimeout(() => {
678
+ cleanup();
679
+ sendCancellation();
680
+ const error = new Error(`Desktop companion command timed out: ${command}`);
681
+ error.code = 'DESKTOP_COMPANION_COMMAND_TIMEOUT';
682
+ reject(error);
611
683
  }, timeoutMs);
612
- this.pending.set(id, { resolve, reject, timer, command });
684
+ timer.unref?.();
685
+ if (signal?.aborted) {
686
+ onAbort();
687
+ return;
688
+ }
689
+ signal?.addEventListener('abort', onAbort, { once: true });
690
+ this.pending.set(id, { resolve, reject, timer, command, signal, onAbort });
613
691
  try {
614
692
  this.ws.send(JSON.stringify(message));
615
693
  } catch (error) {
616
- clearTimeout(timer);
617
- this.pending.delete(id);
694
+ cleanup();
618
695
  reject(error);
619
696
  }
620
697
  });
@@ -636,10 +713,14 @@ class DesktopCompanionConnection {
636
713
 
637
714
  if (message.type === 'event') {
638
715
  if (message.event === 'statusChanged' || message.event === 'permissionsChanged') {
639
- this.registry.touchConnection(this.userId, this.deviceId, {
640
- ...message.payload,
641
- metadata: message.payload?.metadata,
642
- });
716
+ try {
717
+ this.registry.touchConnection(this.userId, this.deviceId, {
718
+ ...message.payload,
719
+ metadata: message.payload?.metadata,
720
+ });
721
+ } catch (error) {
722
+ console.warn('[DesktopCompanion] Failed to record device event:', error?.message);
723
+ }
643
724
  }
644
725
  return;
645
726
  }
@@ -648,6 +729,7 @@ class DesktopCompanionConnection {
648
729
  const pending = this.pending.get(message.id);
649
730
  if (!pending) return;
650
731
  clearTimeout(pending.timer);
732
+ pending.signal?.removeEventListener('abort', pending.onAbort);
651
733
  this.pending.delete(message.id);
652
734
  if (message.ok === false) {
653
735
  const error = new Error(String(message.error || `Desktop companion command failed: ${pending.command}`));
@@ -655,16 +737,18 @@ class DesktopCompanionConnection {
655
737
  pending.reject(error);
656
738
  return;
657
739
  }
658
- pending.resolve(message.payload || {});
740
+ pending.resolve(message.payload ?? {});
659
741
  }
660
742
 
661
743
  _closePending(error) {
744
+ this.closed = true;
662
745
  if (this.heartbeatTimer) {
663
746
  clearInterval(this.heartbeatTimer);
664
747
  this.heartbeatTimer = null;
665
748
  }
666
749
  for (const pending of this.pending.values()) {
667
750
  clearTimeout(pending.timer);
751
+ pending.signal?.removeEventListener('abort', pending.onAbort);
668
752
  pending.reject(error);
669
753
  }
670
754
  this.pending.clear();
@@ -702,5 +786,6 @@ class DesktopCompanionConnection {
702
786
  }
703
787
 
704
788
  module.exports = {
789
+ DesktopCompanionConnection,
705
790
  DesktopCompanionRegistry,
706
791
  };
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ const BITWARDEN_PROVIDER_KEY = 'bitwarden';
4
+
5
+ const BITWARDEN_APP = {
6
+ id: 'password_manager',
7
+ label: 'Password manager',
8
+ description: 'Use selected Bitwarden items without exposing their secret values to the AI.',
9
+ };
10
+
11
+ module.exports = {
12
+ BITWARDEN_APP,
13
+ BITWARDEN_PROVIDER_KEY,
14
+ };