neoagent 2.4.0 → 2.4.1-beta.6

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 (30) hide show
  1. package/LICENSE +619 -21
  2. package/README.md +1 -1
  3. package/extensions/chrome-browser/icons/icon128.png +0 -0
  4. package/extensions/chrome-browser/icons/icon16.png +0 -0
  5. package/extensions/chrome-browser/icons/icon48.png +0 -0
  6. package/extensions/chrome-browser/icons/logo.svg +12 -0
  7. package/extensions/chrome-browser/manifest.json +11 -1
  8. package/extensions/chrome-browser/popup.css +5 -0
  9. package/extensions/chrome-browser/popup.html +2 -4
  10. package/extensions/chrome-browser/popup.js +1 -5
  11. package/flutter_app/lib/main_controller.dart +16 -0
  12. package/flutter_app/lib/main_devices.dart +70 -1
  13. package/flutter_app/lib/main_settings.dart +215 -31
  14. package/flutter_app/lib/src/backend_client.dart +12 -0
  15. package/flutter_app/lib/src/desktop_companion_actions.dart +72 -0
  16. package/flutter_app/lib/src/desktop_companion_io.dart +9 -4
  17. package/flutter_app/macos/Runner/AppDelegate.swift +12 -1
  18. package/package.json +2 -2
  19. package/server/guest_agent.js +12 -1
  20. package/server/http/routes.js +190 -0
  21. package/server/public/.last_build_id +1 -1
  22. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  23. package/server/public/flutter_bootstrap.js +1 -1
  24. package/server/public/main.dart.js +51613 -51262
  25. package/server/services/ai/tools.js +39 -28
  26. package/server/services/desktop/protocol.js +1 -0
  27. package/server/services/desktop/provider.js +11 -0
  28. package/server/services/desktop/registry.js +51 -10
  29. package/server/services/runtime/docker-vm-manager.js +26 -3
  30. package/server/services/runtime/manager.js +25 -2
@@ -292,7 +292,7 @@ function getAvailableTools(app, options = {}) {
292
292
  const tools = [
293
293
  {
294
294
  name: 'execute_command',
295
- description: 'Execute a terminal/shell command as a normal recoverable agent step. Waits for the process to exit, supports PTY for interactive programs, and returns stdout, stderr, exit code, timeout state, and duration so later reasoning can inspect failures, install missing dependencies, and retry when needed.',
295
+ description: 'Execute a terminal/shell command as a normal recoverable agent step. Waits for the process to exit, supports PTY for interactive programs, and returns stdout, stderr, exit code, timeout state, duration, and a backend field ("vm" or "desktop-companion") indicating where the command ran. Commands run inside the isolated VM unless the cli_backend setting is set to "desktop" and a companion app is connected, in which case the command runs on the companion desktop machine.',
296
296
  parameters: {
297
297
  type: 'object',
298
298
  properties: {
@@ -308,7 +308,7 @@ function getAvailableTools(app, options = {}) {
308
308
  },
309
309
  {
310
310
  name: 'browser_navigate',
311
- description: 'Navigate the browser to a URL and return page content/screenshot',
311
+ description: 'Navigate the browser to a URL and return page content/screenshot. The result includes a backend field ("vm" or "extension") indicating whether the VM browser or the paired browser extension handled the request.',
312
312
  parameters: {
313
313
  type: 'object',
314
314
  properties: {
@@ -1401,10 +1401,13 @@ async function executeTool(toolName, args, context, engine) {
1401
1401
  allowMultipleProactiveMessages = false
1402
1402
  } = context;
1403
1403
  const runtime = () => app?.locals?.runtimeManager || engine.runtimeManager || null;
1404
- const bc = () => {
1404
+ const bc = async () => {
1405
1405
  const manager = runtime();
1406
1406
  if (manager && typeof manager.getBrowserProviderForUser === 'function') {
1407
- return manager.getBrowserProviderForUser(userId);
1407
+ const backend = typeof manager.getActiveBrowserBackend === 'function'
1408
+ ? manager.getActiveBrowserBackend(userId)
1409
+ : 'vm';
1410
+ return { provider: await manager.getBrowserProviderForUser(userId), backend };
1408
1411
  }
1409
1412
  throw new Error('Browser provider is unavailable. VM runtime is required.');
1410
1413
  };
@@ -1478,59 +1481,67 @@ async function executeTool(toolName, args, context, engine) {
1478
1481
 
1479
1482
  case 'execute_command': {
1480
1483
  const runtimeManager = runtime();
1481
- if (!runtimeManager || typeof runtimeManager.executeCommand !== 'function') {
1482
- return { error: 'Command execution is unavailable. VM runtime is required.' };
1484
+ if (!runtimeManager) {
1485
+ return { error: 'Command execution is unavailable. No runtime manager found.' };
1483
1486
  }
1484
- return await runtimeManager.executeCommand(userId, args.command, {
1487
+ const execOptions = {
1485
1488
  cwd: args.cwd,
1486
1489
  timeout: args.timeout || (args.pty ? 20 * 60 * 1000 : 15 * 60 * 1000),
1487
1490
  stdinInput: args.stdin_input,
1488
1491
  pty: args.pty === true,
1489
1492
  inputs: args.inputs || [],
1490
- });
1493
+ };
1494
+ if (typeof runtimeManager.executeCliCommand === 'function') {
1495
+ return await runtimeManager.executeCliCommand(userId, args.command, execOptions);
1496
+ }
1497
+ // Legacy fallback — older runtime manager without CLI routing.
1498
+ if (typeof runtimeManager.executeCommand !== 'function') {
1499
+ return { error: 'Command execution is unavailable. VM runtime is required.' };
1500
+ }
1501
+ return { ...await runtimeManager.executeCommand(userId, args.command, execOptions), backend: 'vm' };
1491
1502
  }
1492
1503
 
1493
1504
  case 'browser_navigate': {
1494
- const controller = await bc();
1495
- if (!controller) return { error: 'Browser controller not available' };
1496
- return await controller.navigate(args.url, {
1505
+ const { provider, backend } = await bc();
1506
+ if (!provider) return { error: 'Browser controller not available' };
1507
+ return { ...await provider.navigate(args.url, {
1497
1508
  screenshot: args.screenshot !== false,
1498
1509
  waitFor: args.waitFor,
1499
1510
  fullPage: args.fullPage
1500
- });
1511
+ }), backend };
1501
1512
  }
1502
1513
 
1503
1514
  case 'browser_click': {
1504
- const controller = await bc();
1505
- if (!controller) return { error: 'Browser controller not available' };
1506
- return await controller.click(args.selector, args.text, args.screenshot !== false);
1515
+ const { provider, backend } = await bc();
1516
+ if (!provider) return { error: 'Browser controller not available' };
1517
+ return { ...await provider.click(args.selector, args.text, args.screenshot !== false), backend };
1507
1518
  }
1508
1519
 
1509
1520
  case 'browser_type': {
1510
- const controller = await bc();
1511
- if (!controller) return { error: 'Browser controller not available' };
1512
- return await controller.type(args.selector, args.text, {
1521
+ const { provider, backend } = await bc();
1522
+ if (!provider) return { error: 'Browser controller not available' };
1523
+ return { ...await provider.type(args.selector, args.text, {
1513
1524
  clear: args.clear !== false,
1514
1525
  pressEnter: args.pressEnter
1515
- });
1526
+ }), backend };
1516
1527
  }
1517
1528
 
1518
1529
  case 'browser_extract': {
1519
- const controller = await bc();
1520
- if (!controller) return { error: 'Browser controller not available' };
1521
- return await controller.extract(args.selector, args.attribute, args.all);
1530
+ const { provider, backend } = await bc();
1531
+ if (!provider) return { error: 'Browser controller not available' };
1532
+ return { ...await provider.extract(args.selector, args.attribute, args.all), backend };
1522
1533
  }
1523
1534
 
1524
1535
  case 'browser_screenshot': {
1525
- const controller = await bc();
1526
- if (!controller) return { error: 'Browser controller not available' };
1527
- return await controller.screenshot({ fullPage: args.fullPage, selector: args.selector });
1536
+ const { provider, backend } = await bc();
1537
+ if (!provider) return { error: 'Browser controller not available' };
1538
+ return { ...await provider.screenshot({ fullPage: args.fullPage, selector: args.selector }), backend };
1528
1539
  }
1529
1540
 
1530
1541
  case 'browser_evaluate': {
1531
- const controller = await bc();
1532
- if (!controller) return { error: 'Browser controller not available' };
1533
- return await controller.evaluate(args.script);
1542
+ const { provider, backend } = await bc();
1543
+ if (!provider) return { error: 'Browser controller not available' };
1544
+ return { ...await provider.evaluate(args.script), backend };
1534
1545
  }
1535
1546
 
1536
1547
  case 'android_start_emulator': {
@@ -14,6 +14,7 @@ const DESKTOP_COMMANDS = Object.freeze({
14
14
  SELECT_DISPLAY: 'selectDisplay',
15
15
  GET_TREE: 'getTree',
16
16
  PAUSE_CONTROL: 'pauseControl',
17
+ EXECUTE_COMMAND: 'executeCommand',
17
18
  PING: 'ping',
18
19
  });
19
20
 
@@ -169,6 +169,17 @@ class DesktopProvider {
169
169
  getAccessibilityTree(options = {}) {
170
170
  return this._dispatch(DESKTOP_COMMANDS.GET_TREE, options);
171
171
  }
172
+
173
+ executeCommand(command, options = {}) {
174
+ return this._dispatch(DESKTOP_COMMANDS.EXECUTE_COMMAND, {
175
+ command,
176
+ cwd: options.cwd || null,
177
+ timeout: options.timeout || null,
178
+ stdin_input: options.stdinInput || null,
179
+ pty: options.pty === true,
180
+ inputs: options.inputs || [],
181
+ });
182
+ }
172
183
  }
173
184
 
174
185
  module.exports = {
@@ -127,6 +127,16 @@ class DesktopCompanionRegistry {
127
127
  now,
128
128
  );
129
129
 
130
+ // Remove stale offline entries for the same machine (e.g. after a re-install
131
+ // that generated a new device_id but kept the same hostname).
132
+ const hostname = hello.hostname ? String(hello.hostname).trim() : null;
133
+ if (hostname) {
134
+ this.db.prepare(
135
+ `DELETE FROM desktop_companion_devices
136
+ WHERE user_id = ? AND hostname = ? AND device_id != ? AND status = 'offline'`
137
+ ).run(userId, hostname, hello.deviceId);
138
+ }
139
+
130
140
  return this.getDeviceRecordByDeviceId(userId, hello.deviceId);
131
141
  }
132
142
 
@@ -172,9 +182,6 @@ class DesktopCompanionRegistry {
172
182
  const record = this._upsertDeviceRecord(userId, hello, sessionId);
173
183
  const userMap = this._getUserMap(userId, true);
174
184
  const existing = userMap.get(record.deviceId);
175
- if (existing && existing.ws !== ws) {
176
- existing.close('replaced by a newer desktop companion connection');
177
- }
178
185
 
179
186
  const connection = new DesktopCompanionConnection({
180
187
  registry: this,
@@ -192,7 +199,16 @@ class DesktopCompanionRegistry {
192
199
  },
193
200
  timeoutMs: this.commandTimeoutMs,
194
201
  });
202
+ // Install the new connection in the map BEFORE closing the old one.
203
+ // This ensures that when the old socket's async 'close' event fires and
204
+ // calls unregisterConnection, it sees the new connection as the owner and
205
+ // skips the DB status='offline' write — preventing a false offline report.
195
206
  userMap.set(record.deviceId, connection);
207
+
208
+ if (existing && existing.ws !== ws) {
209
+ existing.close('replaced by a newer desktop companion connection');
210
+ }
211
+
196
212
  return {
197
213
  connection,
198
214
  device: this.getDeviceRecordByDeviceId(userId, record.deviceId),
@@ -201,17 +217,21 @@ class DesktopCompanionRegistry {
201
217
 
202
218
  unregisterConnection(connection) {
203
219
  const userMap = this._getUserMap(connection.userId);
204
- if (userMap && userMap.get(connection.deviceId) === connection) {
220
+ const isOwner = userMap != null && userMap.get(connection.deviceId) === connection;
221
+ if (isOwner) {
205
222
  userMap.delete(connection.deviceId);
206
223
  if (userMap.size === 0) {
207
224
  this.connectionsByUser.delete(String(connection.userId));
208
225
  }
226
+ // Only mark offline in the DB when this connection is still the active owner.
227
+ // If a newer connection has already taken over (reconnect race), its
228
+ // _upsertDeviceRecord already wrote status='online' and we must not clobber it.
229
+ this.db.prepare(
230
+ `UPDATE desktop_companion_devices
231
+ SET status = 'offline', updated_at = datetime('now')
232
+ WHERE user_id = ? AND device_id = ?`
233
+ ).run(connection.userId, connection.deviceId);
209
234
  }
210
- this.db.prepare(
211
- `UPDATE desktop_companion_devices
212
- SET status = 'offline', updated_at = datetime('now')
213
- WHERE user_id = ? AND device_id = ?`
214
- ).run(connection.userId, connection.deviceId);
215
235
  }
216
236
 
217
237
  touchConnection(userId, deviceId, patch = {}) {
@@ -253,6 +273,11 @@ class DesktopCompanionRegistry {
253
273
  return this.getDeviceRecordByDeviceId(userId, deviceId);
254
274
  }
255
275
 
276
+ isConnected(userId) {
277
+ const userMap = this._getUserMap(userId);
278
+ return userMap != null && userMap.size > 0;
279
+ }
280
+
256
281
  getConnection(userId, deviceId) {
257
282
  const userMap = this._getUserMap(userId);
258
283
  if (!userMap) return null;
@@ -351,9 +376,19 @@ class DesktopCompanionRegistry {
351
376
  getStatus(userId) {
352
377
  const devices = this.listDevices(userId);
353
378
  const onlineDevices = devices.filter((device) => device.online);
379
+ let selectedDeviceId = this.getSelectedDeviceId(userId);
380
+
381
+ // Auto-select the most-recently-online device when there is no valid selection.
382
+ // listDevices returns online devices first, ordered by last_seen_at DESC.
383
+ const selectionIsOnline = selectedDeviceId && onlineDevices.some((d) => d.deviceId === selectedDeviceId);
384
+ if (!selectionIsOnline && onlineDevices.length > 0) {
385
+ selectedDeviceId = onlineDevices[0].deviceId;
386
+ this.setSelectedDeviceId(userId, selectedDeviceId);
387
+ }
388
+
354
389
  return {
355
390
  connected: onlineDevices.length > 0,
356
- selectedDeviceId: this.getSelectedDeviceId(userId),
391
+ selectedDeviceId,
357
392
  onlineCount: onlineDevices.length,
358
393
  devices,
359
394
  };
@@ -446,6 +481,8 @@ class DesktopCompanionConnection {
446
481
  this.ws.close(1000, String(reason || 'closing').slice(0, 120));
447
482
  }
448
483
  } catch {}
484
+ // unregisterConnection is intentionally called here (not inside _closePending)
485
+ // so it runs synchronously before any async ws 'close' event can fire.
449
486
  this.registry.unregisterConnection(this);
450
487
  this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.'));
451
488
  }
@@ -512,6 +549,10 @@ class DesktopCompanionConnection {
512
549
  pending.reject(error);
513
550
  }
514
551
  this.pending.clear();
552
+ // unregisterConnection is idempotent (ownership-checked) so calling it
553
+ // here is safe whether we arrived via close(), the ws 'close' event, or
554
+ // both. It ensures natural socket drops (no explicit close() call) still
555
+ // mark the device offline.
515
556
  this.registry.unregisterConnection(this);
516
557
  }
517
558
  }
@@ -102,17 +102,38 @@ const server = http.createServer(async (req, res) => {
102
102
 
103
103
  // ── CLI execution ──────────────────────────────────────────────────────
104
104
  if (req.method === 'POST' && url === '/exec') {
105
+ const timeoutMs = Math.min(Number(b.timeout) || 15 * 60 * 1000, 20 * 60 * 1000);
105
106
  const child = spawn('sh', ['-c', b.command || 'true'], {
106
107
  cwd: b.cwd || '/tmp',
107
108
  env: { ...process.env, ...b.env },
108
109
  });
109
110
  const pid = child.pid;
110
- let stdout = '', stderr = '';
111
+ let stdout = '', stderr = '', settled = false;
111
112
  procs.set(pid, child);
112
113
  child.stdout.on('data', d => { stdout += d; });
113
114
  child.stderr.on('data', d => { stderr += d; });
114
- child.on('close', code => { procs.delete(pid); json(res, { stdout, stderr, code: code ?? 1, pid }); });
115
- child.on('error', err => { procs.delete(pid); json(res, { stdout, stderr, code: 1, pid, error: err.message }); });
115
+ const timer = setTimeout(() => {
116
+ if (settled) return;
117
+ settled = true;
118
+ procs.delete(pid);
119
+ try { child.kill('SIGKILL'); } catch {}
120
+ json(res, { stdout, stderr, exitCode: null, code: null, pid, timedOut: true, killed: true });
121
+ }, timeoutMs);
122
+ child.on('close', code => {
123
+ if (settled) return;
124
+ settled = true;
125
+ clearTimeout(timer);
126
+ procs.delete(pid);
127
+ const exitCode = code ?? 1;
128
+ json(res, { stdout, stderr, exitCode, code: exitCode, pid, timedOut: false, killed: false });
129
+ });
130
+ child.on('error', err => {
131
+ if (settled) return;
132
+ settled = true;
133
+ clearTimeout(timer);
134
+ procs.delete(pid);
135
+ json(res, { stdout, stderr, exitCode: 1, code: 1, pid, error: err.message, timedOut: false, killed: false });
136
+ });
116
137
  return;
117
138
  }
118
139
 
@@ -268,6 +289,7 @@ class DockerVMManager {
268
289
  this.image = options.image || CONTAINER_IMAGE;
269
290
  this.memoryMb = options.memoryMb || 2048;
270
291
  this.cpus = options.cpus || 2;
292
+ this.guestToken = String(options.guestToken || process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim();
271
293
  this.#cleanupOrphans();
272
294
  }
273
295
 
@@ -309,6 +331,7 @@ class DockerVMManager {
309
331
  '--cpus', String(this.cpus),
310
332
  '-p', `127.0.0.1:${port}:${port}`,
311
333
  '-e', `AGENT_PORT=${port}`,
334
+ ...(this.guestToken ? ['-e', `NEOAGENT_VM_GUEST_TOKEN=${this.guestToken}`] : []),
312
335
  '--shm-size=2g',
313
336
  '--security-opt', 'no-new-privileges',
314
337
  '--label', CONTAINER_LABEL,
@@ -100,9 +100,32 @@ class RuntimeManager {
100
100
  async getCliProviderForUser(userId) {
101
101
  const settings = this.getSettings(userId);
102
102
  if (settings.cli_backend === 'desktop' && this.hasActiveDesktopCompanion(userId)) {
103
- return this.getDesktopCliProvider(userId);
103
+ const desktopProvider = this.getDesktopCliProvider(userId);
104
+ return {
105
+ backend: 'desktop-companion',
106
+ execute: (command, options = {}) => desktopProvider.executeCommand(command, options),
107
+ executeInteractive: (command, inputs = [], options = {}) => desktopProvider.executeCommand(command, { ...options, inputs }),
108
+ kill: () => Promise.resolve(false),
109
+ };
104
110
  }
105
- return this.browserBackend.getCommandExecutorForUser(userId);
111
+ const executor = await this.browserBackend.getCommandExecutorForUser(userId);
112
+ return { ...executor, backend: 'vm' };
113
+ }
114
+
115
+ async executeCliCommand(userId, command, options = {}) {
116
+ const provider = await this.getCliProviderForUser(userId);
117
+ const result = await (options.pty === true && provider.executeInteractive
118
+ ? provider.executeInteractive(command, options.inputs || [], options)
119
+ : provider.execute(command, options));
120
+ return { ...result, backend: provider.backend };
121
+ }
122
+
123
+ getActiveBrowserBackend(userId) {
124
+ const settings = this.getSettings(userId);
125
+ if (settings.browser_backend === 'extension' && this.hasActiveExtensionBrowser(userId)) {
126
+ return 'extension';
127
+ }
128
+ return 'vm';
106
129
  }
107
130
 
108
131
  async getAndroidProviderForUser(userId) {