farming-code 2.2.28 → 2.2.29

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 (74) hide show
  1. package/README.md +9 -31
  2. package/README.zh_cn.md +8 -30
  3. package/THIRD_PARTY_NOTICES.md +25 -6
  4. package/backend/acp/packaged-claude-acp.js +13 -0
  5. package/backend/acp/packaged-codex-acp.js +2 -2
  6. package/backend/acp-runtime.js +37 -2
  7. package/backend/acp-transcript.js +174 -9
  8. package/backend/agent-manager.js +27 -2
  9. package/backend/config-manager.js +44 -0
  10. package/backend/data/LICENSE.agent-browser +201 -0
  11. package/backend/data/runtime-dependency-manifest.json +199 -0
  12. package/backend/data/runtime-dependency-sources.json +5 -0
  13. package/backend/executable-discovery.js +7 -0
  14. package/backend/farming-agent-bootstrap.zh_cn.md +4 -2
  15. package/backend/farming-app-cli.js +160 -64
  16. package/backend/farming-cli.js +5 -3
  17. package/backend/index-html.js +13 -0
  18. package/backend/main-agent-skills.js +6 -10
  19. package/backend/npm-update-helper.js +43 -6
  20. package/backend/provider-adapters.js +10 -0
  21. package/backend/runtime-dependency-manager.js +759 -0
  22. package/backend/runtime-executable-invocation.js +20 -0
  23. package/backend/server-process-identity.js +47 -0
  24. package/backend/server.js +140 -157
  25. package/backend/slash-command-discovery.js +138 -1
  26. package/backend/storage-layout.js +15 -0
  27. package/backend/update-service.js +27 -1
  28. package/config/farming.install.env.example +4 -0
  29. package/dist/acp/LICENSE.claude-agent-acp +191 -0
  30. package/dist/acp/LICENSE.claude-agent-sdk +1 -0
  31. package/dist/acp/claude-agent-acp-0.59.0.mjs +41606 -0
  32. package/dist/assets/{App-CM7Cy-l4.css → App-BKNUILuL.css} +1 -1
  33. package/dist/assets/App-Ex2oVVXO.js +693 -0
  34. package/dist/assets/{FileEditorMarkdownPreview-DYOMm-b1.js → FileEditorMarkdownPreview-Cs9cLv8a.js} +1 -1
  35. package/dist/assets/FileEditorPane-CNtuGjQ3.js +2 -0
  36. package/dist/assets/IconGlyphs-BClgag6E.js +1 -0
  37. package/dist/assets/ProjectFilesSection-Cf2BEcAN.js +12 -0
  38. package/dist/assets/ReviewPage-BRuxFaH5.js +3 -0
  39. package/dist/assets/code-dark-BEZTQwYZ.css +1 -0
  40. package/dist/assets/{index-BzQO3pUr.js → index-CQNaZc3l.js} +2 -2
  41. package/dist/assets/main-DK9Sy7-c.css +1 -0
  42. package/dist/assets/{workspace-editor-monaco-tXA-udGx.js → workspace-editor-monaco-CJNwbpCy.js} +1 -1
  43. package/dist/assets/workspace-editor-monaco-CO9w8YV0.js +1 -0
  44. package/dist/assets/workspace-view-state-BjKPYp0E.js +7 -0
  45. package/dist/index.html +32 -3
  46. package/extensions/browser/backend/agent-browser-runtime.js +1041 -0
  47. package/extensions/browser/backend/browser-mcp-server.js +352 -0
  48. package/extensions/browser/backend/browser-resource-manager.js +899 -114
  49. package/extensions/browser/backend/browser-resource-store.js +47 -6
  50. package/extensions/browser/backend/browser-router.js +7 -2
  51. package/extensions/browser/backend/executable-discovery.js +146 -13
  52. package/extensions/browser/bin/farming-browser +384 -30
  53. package/extensions/browser/frontend/BrowserSidebarPortals.tsx +17 -19
  54. package/extensions/browser/frontend/BrowserViewer.tsx +217 -46
  55. package/extensions/browser/frontend/browser.css +68 -17
  56. package/extensions/browser/frontend/types.ts +6 -0
  57. package/extensions/browser/frontend/useBrowserResources.ts +10 -3
  58. package/index.html +31 -2
  59. package/package.json +10 -6
  60. package/shared/backend-connection-status.d.ts +12 -0
  61. package/shared/backend-connection-status.js +35 -4
  62. package/backend/agent-activity.js +0 -35
  63. package/dist/assets/App-CPG4WcjO.js +0 -658
  64. package/dist/assets/FileEditorPane-DXXq82aM.js +0 -2
  65. package/dist/assets/IconGlyphs-DrdPjYXr.js +0 -1
  66. package/dist/assets/ProjectFilesSection-CP66XVPl.js +0 -12
  67. package/dist/assets/ReviewPage-w21MEPD6.js +0 -3
  68. package/dist/assets/code-dark-BDjwj7Um.css +0 -1
  69. package/dist/assets/main-BlVUXfxa.css +0 -1
  70. package/dist/assets/workspace-editor-monaco-CFEfFNcA.js +0 -1
  71. package/dist/assets/workspace-view-state-BS_V8JVs.js +0 -7
  72. package/extensions/browser/backend/cdp-browser-runtime.js +0 -648
  73. package/extensions/browser/backend/cdp-client.js +0 -167
  74. /package/dist/acp/{codex-acp-1.1.4.js → codex-acp-1.1.4.mjs} +0 -0
@@ -1,12 +1,24 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const crypto = require('crypto');
3
4
  const { EventEmitter } = require('events');
4
5
  const storageLayout = require('../../../backend/storage-layout');
6
+ const {
7
+ matchingProcessIdentity,
8
+ readServerProcessIdentity,
9
+ } = require('../../../backend/server-process-identity');
5
10
  const { BrowserResourceStore, RESOURCE_ID_RE } = require('./browser-resource-store');
6
- const { CdpBrowserRuntime } = require('./cdp-browser-runtime');
7
- const { discoverBrowserExecutable } = require('./executable-discovery');
11
+ const { AgentBrowserRuntime } = require('./agent-browser-runtime');
12
+ const {
13
+ discoverBrowserExecutables,
14
+ discoverBrowserRuntime,
15
+ } = require('./executable-discovery');
8
16
 
9
17
  const MAX_VIEWER_BUFFER_BYTES = 2 * 1024 * 1024;
18
+ const VIEWER_RESIZE_SETTLE_MS = 80;
19
+ const BROWSER_RECOVERY_TIMEOUT_MS = 5_000;
20
+ const BROWSER_RECOVERY_POLL_MS = 100;
21
+ const MAX_UPLOAD_FILES = 20;
10
22
 
11
23
  function publicResource(resource, collectionRevision) {
12
24
  return {
@@ -34,11 +46,32 @@ function browserError(message, status = 400, code = 'BROWSER_INVALID_REQUEST') {
34
46
  return error;
35
47
  }
36
48
 
49
+ function externalBrowserFailure(action, cause) {
50
+ const error = browserError(
51
+ `${action}; verify the Browser plugin's external CDP address and the browser's /json/version endpoint`,
52
+ 500,
53
+ 'BROWSER_EXTERNAL_CDP_FAILED',
54
+ );
55
+ error.cause = cause;
56
+ return error;
57
+ }
58
+
37
59
  function normalizeUrl(value) {
38
60
  const input = String(value || '').trim();
39
61
  if (!input) return 'about:blank';
40
62
  if (input === 'about:blank') return input;
41
- const url = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(input) ? input : `http://${input}`;
63
+ let url = input;
64
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(input)) {
65
+ const authority = input.split(/[/?#]/, 1)[0];
66
+ const hostname = authority.replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
67
+ const explicitPort = authority.match(/:(\d+)$/)?.[1] || '';
68
+ const isIpLiteral = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || authority.startsWith('[');
69
+ const localHost = hostname === 'localhost'
70
+ || hostname.endsWith('.localhost')
71
+ || !hostname.includes('.')
72
+ || isIpLiteral;
73
+ url = `${localHost || (explicitPort && explicitPort !== '443') ? 'http' : 'https'}://${input}`;
74
+ }
42
75
  try {
43
76
  const parsed = new URL(url);
44
77
  if (!['http:', 'https:'].includes(parsed.protocol)) {
@@ -51,36 +84,260 @@ function normalizeUrl(value) {
51
84
  }
52
85
  }
53
86
 
87
+ function tabResourceName(tab) {
88
+ const title = String(tab?.title || '').trim();
89
+ if (title) return title.slice(0, 120);
90
+ try {
91
+ return new URL(String(tab?.url || '')).hostname.slice(0, 120) || 'Browser';
92
+ } catch {
93
+ return 'Browser';
94
+ }
95
+ }
96
+
97
+ function pathInside(root, candidate) {
98
+ return candidate === root || candidate.startsWith(`${root}${path.sep}`);
99
+ }
100
+
101
+ function resolveWorkspaceInputFile(resource, value) {
102
+ const workspace = fs.realpathSync(resource.workspace);
103
+ const requested = path.resolve(resource.workspace, String(value || ''));
104
+ let resolved;
105
+ try {
106
+ resolved = fs.realpathSync(requested);
107
+ } catch {
108
+ throw browserError(`Upload file does not exist: ${value}`);
109
+ }
110
+ if (!pathInside(workspace, resolved)) {
111
+ throw browserError('Browser uploads must stay inside the Browser Project workspace');
112
+ }
113
+ if (!fs.statSync(resolved).isFile()) {
114
+ throw browserError(`Browser upload path is not a file: ${value}`);
115
+ }
116
+ return resolved;
117
+ }
118
+
119
+ function resolveWorkspaceOutputFile(resource, value) {
120
+ const requestedValue = String(value || '').trim();
121
+ if (!requestedValue) throw browserError('Download output path is required');
122
+ const workspace = fs.realpathSync(resource.workspace);
123
+ const requested = path.resolve(resource.workspace, requestedValue);
124
+ if (!pathInside(path.resolve(resource.workspace), requested)) {
125
+ throw browserError('Browser downloads must stay inside the Browser Project workspace');
126
+ }
127
+ let parent;
128
+ try {
129
+ parent = fs.realpathSync(path.dirname(requested));
130
+ } catch {
131
+ throw browserError('Browser download parent directory does not exist');
132
+ }
133
+ if (!pathInside(workspace, parent)) {
134
+ throw browserError('Browser downloads must stay inside the Browser Project workspace');
135
+ }
136
+ if (fs.existsSync(requested)) {
137
+ throw browserError('Browser download target already exists');
138
+ }
139
+ return requested;
140
+ }
141
+
54
142
  class BrowserResourceManager extends EventEmitter {
55
143
  constructor(options) {
56
144
  super();
57
145
  this.configDir = options.configDir;
58
146
  this.store = options.store || new BrowserResourceStore(options.configDir);
59
- this.discoverExecutable = options.discoverExecutable || (() => discoverBrowserExecutable(options));
60
- this.createRuntime = options.createRuntime || (input => new CdpBrowserRuntime(input));
147
+ this.discoverExecutable = options.discoverExecutable || (selection => discoverBrowserRuntime({
148
+ ...options,
149
+ ...selection,
150
+ configDir: this.configDir,
151
+ }));
152
+ this.discoverBrowserOptions = options.discoverBrowserOptions
153
+ || (options.discoverExecutable ? () => [] : () => discoverBrowserExecutables(options));
154
+ this.getBrowserSettings = typeof options.getBrowserSettings === 'function'
155
+ ? options.getBrowserSettings
156
+ : () => ({ browserSource: 'system', browserExecutablePath: '', browserExternalCdpUrl: '' });
157
+ this.createRuntime = options.createRuntime || (input => new AgentBrowserRuntime(input));
158
+ this.recoverRuntime = options.recoverRuntime || (input => AgentBrowserRuntime.recover(input));
61
159
  this.isEnabled = typeof options.isEnabled === 'function' ? options.isEnabled : () => true;
160
+ this.readProcessIdentity = options.readProcessIdentity || readServerProcessIdentity;
161
+ this.killProcessGroup = options.killProcessGroup || ((processGroupId, signal) => process.kill(-processGroupId, signal));
162
+ this.wait = options.wait || (durationMs => new Promise(resolve => setTimeout(resolve, durationMs)));
163
+ this.scheduleTimeout = options.scheduleTimeout || setTimeout;
164
+ this.cancelTimeout = options.cancelTimeout || clearTimeout;
62
165
  this.runtimes = new Map();
166
+ this.sessions = new Map();
63
167
  this.operations = new Map();
64
168
  this.disposed = false;
169
+ this.runtimeCapability = null;
170
+ this.browserOptions = [];
65
171
  }
66
172
 
67
- init() {
173
+ async init() {
68
174
  this.store.init();
175
+ await this.refreshCapability();
176
+ const interrupted = this.store.list().filter(resource =>
177
+ ['running', 'starting', 'stopping'].includes(resource.status)
178
+ );
179
+ const groups = new Map();
180
+ for (const resource of interrupted) {
181
+ const key = resource.runtimeKind === 'agent-browser'
182
+ ? (resource.sessionId || resource.id)
183
+ : resource.id;
184
+ if (!groups.has(key)) groups.set(key, []);
185
+ groups.get(key).push(resource);
186
+ }
187
+ await Promise.all([...groups.values()].map(resources => {
188
+ const owner = resources.find(resource => resource.processIdentity) || resources[0];
189
+ return this.recoverInterruptedRuntime(owner, resources);
190
+ }));
191
+ }
192
+
193
+ async recoverInterruptedRuntime(resource, relatedResources = [resource]) {
194
+ if (resource.runtimeKind === 'agent-browser') {
195
+ const capability = this.runtimeCapability || await this.refreshCapability();
196
+ let runtimeError = null;
197
+ if (!capability || capability.error || !capability.agentBrowserPath) {
198
+ runtimeError = new Error(
199
+ capability?.error
200
+ || 'The exact agent-browser runtime required to clean up this Browser is unavailable',
201
+ );
202
+ } else {
203
+ try {
204
+ await this.recoverRuntime({
205
+ id: resource.sessionId || resource.id,
206
+ generation: resource.sessionGeneration || resource.generation,
207
+ browserKind: resource.browserKind,
208
+ processIdentity: resource.processIdentity,
209
+ configDir: this.configDir,
210
+ profileDir: storageLayout.browserProfileDir(
211
+ this.configDir,
212
+ resource.sessionId || resource.id,
213
+ ),
214
+ agentBrowserPath: capability.agentBrowserPath,
215
+ readProcessIdentity: this.readProcessIdentity,
216
+ wait: this.wait,
217
+ });
218
+ } catch (error) {
219
+ runtimeError = error;
220
+ }
221
+ }
222
+ for (const related of relatedResources) {
223
+ this.store.update(related.id, {
224
+ status: 'failed',
225
+ error: runtimeError
226
+ ? `agent-browser Session cleanup failed: ${runtimeError.message || runtimeError}`
227
+ : 'Farming restarted and cleaned up the previous Browser runtime',
228
+ ...(!runtimeError ? { processIdentity: null } : {}),
229
+ tabId: '',
230
+ });
231
+ }
232
+ return;
233
+ }
234
+
235
+ // Migration cleanup for Browser rows created by Farming's former raw-CDP runtime.
236
+ const expected = resource.processIdentity;
237
+ if (!expected) {
238
+ this.store.update(resource.id, {
239
+ status: 'failed',
240
+ error: resource.browserKind === 'external-cdp'
241
+ ? 'Farming restarted and disconnected from the external Browser'
242
+ : 'Farming restarted before the Browser runtime identity was committed',
243
+ processIdentity: null,
244
+ });
245
+ return;
246
+ }
247
+ const current = await this.readProcessIdentity(expected.pid);
248
+ if (!matchingProcessIdentity(expected, current)) {
249
+ this.store.update(resource.id, {
250
+ status: 'failed',
251
+ error: 'Farming restarted after the previous Browser runtime exited',
252
+ processIdentity: null,
253
+ });
254
+ return;
255
+ }
256
+ if (expected.processGroupId !== expected.pid) {
257
+ this.store.update(resource.id, {
258
+ status: 'failed',
259
+ error: `Previous Browser process ${expected.pid} has an unsafe process-group identity; stop it manually`,
260
+ });
261
+ return;
262
+ }
263
+ try {
264
+ this.killProcessGroup(expected.processGroupId, 'SIGKILL');
265
+ } catch (error) {
266
+ if (error?.code !== 'ESRCH') {
267
+ const permission = error?.code === 'EPERM' || error?.code === 'EACCES';
268
+ this.store.update(resource.id, {
269
+ status: 'failed',
270
+ error: permission
271
+ ? `Farming cannot clean up previous Browser process ${expected.pid} because it lacks permission`
272
+ : `Farming could not clean up previous Browser process ${expected.pid}: ${error?.message || error}`,
273
+ });
274
+ return;
275
+ }
276
+ }
277
+ const startedAt = Date.now();
278
+ while (matchingProcessIdentity(expected, await this.readProcessIdentity(expected.pid))) {
279
+ if (Date.now() - startedAt >= BROWSER_RECOVERY_TIMEOUT_MS) {
280
+ this.store.update(resource.id, {
281
+ status: 'failed',
282
+ error: `Previous Browser process ${expected.pid} did not exit after SIGKILL`,
283
+ });
284
+ return;
285
+ }
286
+ await this.wait(BROWSER_RECOVERY_POLL_MS);
287
+ }
288
+ this.store.update(resource.id, {
289
+ status: 'failed',
290
+ error: 'Farming restarted and cleaned up the previous Browser runtime',
291
+ processIdentity: null,
292
+ });
69
293
  }
70
294
 
71
295
  capability() {
72
- const executable = this.discoverExecutable();
296
+ const executable = this.runtimeCapability;
297
+ const runnable = executable && !executable.error;
73
298
  const enabled = this.isEnabled() === true;
299
+ const selection = this.browserSelection();
74
300
  return {
75
301
  enabled,
76
- available: enabled && Boolean(executable),
77
- browser: executable ? { kind: executable.kind, path: executable.path } : null,
302
+ available: enabled && Boolean(runnable),
303
+ browser: runnable ? { kind: executable.kind, path: executable.path } : null,
304
+ selection,
305
+ options: this.browserOptions.map(option => ({ kind: option.kind, path: option.path })),
78
306
  message: !enabled
79
307
  ? 'Browser extension is disabled'
80
- : (executable ? '' : 'Install a Chromium-based browser to use the system Browser in Farming'),
308
+ : (executable?.error || (runnable
309
+ ? ''
310
+ : 'Install agent-browser and a Chromium-based browser, or configure a loopback external CDP endpoint')),
81
311
  };
82
312
  }
83
313
 
314
+ browserSelection(settings = this.getBrowserSettings()) {
315
+ return {
316
+ source: settings?.browserSource === 'external-cdp' ? 'external-cdp' : 'system',
317
+ executablePath: String(settings?.browserExecutablePath || ''),
318
+ externalCdpUrl: String(settings?.browserExternalCdpUrl || 'http://127.0.0.1:9222'),
319
+ };
320
+ }
321
+
322
+ async probeCapability(selection = this.browserSelection()) {
323
+ const browserOptions = this.discoverBrowserOptions();
324
+ const selectedOption = browserOptions.find(option => option.path === selection.executablePath);
325
+ const runtimeCapability = await this.discoverExecutable({
326
+ source: selection.source,
327
+ executablePath: selection.executablePath,
328
+ executableKind: selectedOption?.kind,
329
+ externalCdpUrl: selection.externalCdpUrl,
330
+ });
331
+ return { browserOptions, runtimeCapability };
332
+ }
333
+
334
+ async refreshCapability(selection = this.browserSelection()) {
335
+ const probe = await this.probeCapability(selection);
336
+ this.browserOptions = probe.browserOptions;
337
+ this.runtimeCapability = probe.runtimeCapability;
338
+ return this.runtimeCapability;
339
+ }
340
+
84
341
  list() {
85
342
  this.requireEnabled();
86
343
  return this.store.list().map(resource => publicResource(resource, this.store.revision));
@@ -116,18 +373,23 @@ class BrowserResourceManager extends EventEmitter {
116
373
  const title = String(name || '').trim();
117
374
  if (!title) throw browserError('Browser name is required');
118
375
  const resource = this.requireStored(id);
119
- const next = this.store.update(resource.id, { name: title.slice(0, 120) });
376
+ const next = this.store.update(resource.id, {
377
+ name: title.slice(0, 120),
378
+ autoName: false,
379
+ });
120
380
  this.emitResource(next);
121
381
  return publicResource(next, this.store.revision);
122
382
  }
123
383
 
124
384
  start(id) {
125
- this.requireAvailable();
385
+ this.requireEnabled();
126
386
  return this.enqueue(id, async () => {
127
387
  const resource = this.requireStored(id);
128
- const existing = this.runtimes.get(id);
129
- if (resource.status === 'running' && existing) return publicResource(resource, this.store.revision);
130
- if (existing) {
388
+ const existingBinding = this.runtimes.get(id);
389
+ if (resource.status === 'running' && existingBinding) {
390
+ return publicResource(resource, this.store.revision);
391
+ }
392
+ if (existingBinding) {
131
393
  throw browserError(
132
394
  'A previous Browser runtime still owns this resource; stop it before restarting',
133
395
  409,
@@ -137,11 +399,20 @@ class BrowserResourceManager extends EventEmitter {
137
399
  if (resource.status === 'starting' || resource.status === 'stopping') {
138
400
  throw browserError(`Browser is ${resource.status}`, 409, 'BROWSER_BUSY');
139
401
  }
140
- const executable = this.discoverExecutable();
141
- if (!executable) {
402
+ if (resource.processIdentity) {
403
+ const blockedIdentity = resource.processIdentity;
404
+ throw browserError(
405
+ `Previous Browser process ${blockedIdentity.pid} still requires cleanup`,
406
+ 409,
407
+ 'BROWSER_RECOVERY_CLEANUP_REQUIRED',
408
+ );
409
+ }
410
+ const executable = await this.refreshCapability();
411
+ if (!executable || executable.error) {
142
412
  const failed = this.store.update(id, {
143
413
  status: 'failed',
144
- error: 'Install a Chromium-based browser to use the system Browser in Farming',
414
+ error: executable?.error
415
+ || 'Install agent-browser and a Chromium-based browser, or configure a loopback external CDP endpoint',
145
416
  });
146
417
  this.emitResource(failed);
147
418
  throw browserError(failed.error, 503, 'BROWSER_EXECUTABLE_NOT_FOUND');
@@ -152,51 +423,146 @@ class BrowserResourceManager extends EventEmitter {
152
423
  status: 'starting',
153
424
  generation,
154
425
  browserKind: executable.kind,
426
+ runtimeKind: 'agent-browser',
155
427
  error: '',
428
+ processIdentity: null,
156
429
  });
157
430
  this.emitResource(starting);
431
+
432
+ const reusableSession = [...this.sessions.values()].find(session => (
433
+ !session.closing
434
+ && session.projectRootId === resource.projectRootId
435
+ && session.browserKind === executable.kind
436
+ ));
437
+ if (reusableSession) {
438
+ try {
439
+ let running;
440
+ let binding;
441
+ const operation = (reusableSession.actionChain || Promise.resolve())
442
+ .catch(() => {})
443
+ .then(async () => {
444
+ const tab = await reusableSession.runtime.createTab(
445
+ resource.url,
446
+ executable.kind === 'external-cdp'
447
+ ? `farming-${resource.id}-g${generation}`
448
+ : '',
449
+ );
450
+ binding = this.createBinding(reusableSession, {
451
+ ...starting,
452
+ tabId: tab.tabId,
453
+ });
454
+ reusableSession.bindings.set(id, binding);
455
+ reusableSession.activeResourceId = id;
456
+ this.runtimes.set(id, binding);
457
+ running = this.store.update(id, {
458
+ status: 'running',
459
+ sessionId: reusableSession.id,
460
+ sessionGeneration: reusableSession.generation,
461
+ tabId: tab.tabId,
462
+ url: tab.url || resource.url,
463
+ title: tab.title || '',
464
+ error: '',
465
+ processIdentity: null,
466
+ });
467
+ });
468
+ reusableSession.actionChain = operation;
469
+ await operation;
470
+ this.emitResource(running);
471
+ this.broadcastRuntimeState(binding);
472
+ return publicResource(running, this.store.revision);
473
+ } catch (error) {
474
+ const failed = this.store.update(id, {
475
+ status: 'failed',
476
+ error: error?.message || 'Failed to create Browser tab',
477
+ tabId: '',
478
+ });
479
+ this.emitResource(failed);
480
+ throw browserError(failed.error, 500, 'BROWSER_START_FAILED');
481
+ }
482
+ }
483
+
484
+ const sessionId = resource.sessionId || id;
485
+ const previousSessionGeneration = this.store.list()
486
+ .filter(candidate => candidate.sessionId === sessionId)
487
+ .reduce((maximum, candidate) => Math.max(maximum, candidate.sessionGeneration || 0), 0);
488
+ const sessionGeneration = previousSessionGeneration + 1;
158
489
  const runtime = this.createRuntime({
159
- id,
160
- generation,
490
+ id: sessionId,
491
+ generation: sessionGeneration,
492
+ configDir: this.configDir,
493
+ agentBrowserPath: executable.agentBrowserPath,
161
494
  executablePath: executable.path,
162
- profileDir: storageLayout.browserProfileDir(this.configDir, id),
495
+ externalCdpUrl: executable.cdpUrl || '',
496
+ profileDir: storageLayout.browserProfileDir(this.configDir, sessionId),
163
497
  });
164
- this.runtimes.set(id, runtime);
165
- this.bindRuntime(runtime);
498
+ const session = {
499
+ id: sessionId,
500
+ generation: sessionGeneration,
501
+ projectRootId: resource.projectRootId,
502
+ browserKind: executable.kind,
503
+ runtime,
504
+ bindings: new Map(),
505
+ activeResourceId: id,
506
+ processOwnerResourceId: id,
507
+ actionChain: Promise.resolve(),
508
+ reconcilingTabs: Promise.resolve(),
509
+ initializing: true,
510
+ closing: false,
511
+ };
512
+ const binding = this.createBinding(session, starting);
513
+ session.bindings.set(id, binding);
514
+ this.sessions.set(sessionId, session);
515
+ this.runtimes.set(id, binding);
516
+ this.bindSession(session);
166
517
  try {
167
518
  const metadata = await runtime.start(resource.url);
168
- if (this.runtimes.get(id) !== runtime) {
519
+ const tabs = await runtime.listTabs();
520
+ const tab = tabs.find(candidate => candidate.active) || tabs[0];
521
+ if (!tab) throw new Error('agent-browser did not report the Browser tab');
522
+ binding.tabId = tab.tabId;
523
+ if (this.runtimes.get(id) !== binding) {
169
524
  throw browserError('Browser startup lost runtime ownership', 409, 'BROWSER_START_REPLACED');
170
525
  }
171
526
  const running = this.store.update(id, {
172
527
  status: 'running',
528
+ sessionId,
529
+ sessionGeneration,
530
+ tabId: tab.tabId,
173
531
  url: metadata.url || resource.url,
174
532
  title: metadata.title || '',
175
533
  error: '',
176
534
  });
535
+ session.initializing = false;
177
536
  this.emitResource(running);
178
- this.broadcastRuntimeState(runtime);
537
+ this.broadcastRuntimeState(binding);
179
538
  return publicResource(running, this.store.revision);
180
539
  } catch (error) {
540
+ session.initializing = false;
181
541
  let cleanupError = null;
182
542
  try {
183
543
  await runtime.close();
184
544
  } catch (closeError) {
185
545
  cleanupError = closeError;
186
546
  }
187
- if (!cleanupError && this.runtimes.get(id) === runtime) this.runtimes.delete(id);
547
+ if (!cleanupError && this.runtimes.get(id) === binding) this.runtimes.delete(id);
548
+ if (!cleanupError && this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
188
549
  const current = this.store.get(id);
550
+ const failureMessage = executable.kind === 'external-cdp'
551
+ ? externalBrowserFailure('External Browser connection failed', error).message
552
+ : error?.message || 'Failed to start Browser';
189
553
  const failed = current?.generation === generation
190
554
  ? this.store.update(id, {
191
555
  status: 'failed',
192
556
  error: cleanupError
193
- ? `${error?.message || 'Failed to start system browser'}; cleanup failed: ${cleanupError.message}`
194
- : error?.message || 'Failed to start system browser',
557
+ ? `${failureMessage}; cleanup failed`
558
+ : failureMessage,
559
+ tabId: '',
560
+ ...(!cleanupError ? { processIdentity: null } : {}),
195
561
  })
196
562
  : null;
197
563
  if (failed) this.emitResource(failed);
198
564
  throw browserError(
199
- failed?.error || error?.message || 'Failed to start system browser',
565
+ failed?.error || error?.message || 'Failed to start Browser',
200
566
  500,
201
567
  'BROWSER_START_FAILED',
202
568
  );
@@ -207,22 +573,76 @@ class BrowserResourceManager extends EventEmitter {
207
573
  stop(id) {
208
574
  this.requireEnabled();
209
575
  return this.enqueue(id, async () => {
210
- this.requireStored(id);
211
- const runtime = this.runtimes.get(id);
212
- if (!runtime) {
213
- const stopped = this.store.update(id, { status: 'stopped', error: '' });
576
+ const resource = this.requireStored(id);
577
+ const binding = this.runtimes.get(id);
578
+ if (!binding) {
579
+ if (resource.processIdentity) {
580
+ await this.recoverInterruptedRuntime(resource);
581
+ const recovered = this.requireStored(id);
582
+ if (recovered.processIdentity) {
583
+ const blockedIdentity = recovered.processIdentity;
584
+ throw browserError(
585
+ recovered.error || `Previous Browser process ${blockedIdentity.pid} still requires cleanup`,
586
+ 500,
587
+ 'BROWSER_RECOVERY_CLEANUP_REQUIRED',
588
+ );
589
+ }
590
+ }
591
+ const stopped = this.store.update(id, {
592
+ status: 'stopped',
593
+ error: '',
594
+ processIdentity: null,
595
+ });
214
596
  this.emitResource(stopped);
215
597
  return publicResource(stopped, this.store.revision);
216
598
  }
217
599
  const stopping = this.store.update(id, { status: 'stopping', error: '' });
218
600
  this.emitResource(stopping);
219
- this.broadcastRuntimeState(runtime);
220
- await runtime.close();
221
- if (this.runtimes.get(id) === runtime) this.runtimes.delete(id);
222
- const stopped = this.store.update(id, { status: 'stopped', error: '' });
601
+ this.broadcastRuntimeState(binding);
602
+ const { session } = binding;
603
+ session.closing = session.bindings.size === 1;
604
+ try {
605
+ const closeOperation = (session.actionChain || Promise.resolve())
606
+ .catch(() => {})
607
+ .then(() => (
608
+ session.bindings.size > 1
609
+ ? session.runtime.closeTab(binding.tabId)
610
+ : session.runtime.close()
611
+ ));
612
+ session.actionChain = closeOperation;
613
+ await closeOperation;
614
+ } catch (error) {
615
+ session.closing = false;
616
+ if (resource.browserKind === 'external-cdp') {
617
+ throw externalBrowserFailure('External Browser targets could not be closed', error);
618
+ }
619
+ throw error;
620
+ }
621
+ session.bindings.delete(id);
622
+ if (this.runtimes.get(id) === binding) this.runtimes.delete(id);
623
+ if (session.bindings.size === 0 && this.sessions.get(session.id) === session) {
624
+ this.sessions.delete(session.id);
625
+ }
626
+ session.closing = false;
627
+
628
+ if (session.processOwnerResourceId === id && session.bindings.size > 0) {
629
+ const nextOwner = session.bindings.values().next().value;
630
+ session.processOwnerResourceId = nextOwner.id;
631
+ const ownerResource = this.store.update(nextOwner.id, {
632
+ processIdentity: resource.processIdentity,
633
+ });
634
+ this.emitResource(ownerResource);
635
+ this.broadcastRuntimeState(nextOwner);
636
+ }
637
+ const stopped = this.store.update(id, {
638
+ status: 'stopped',
639
+ error: '',
640
+ processIdentity: null,
641
+ tabId: '',
642
+ });
223
643
  this.emitResource(stopped);
224
- this.broadcastRuntimeState(runtime);
225
- runtime.viewers?.clear?.();
644
+ this.broadcastRuntimeState(binding);
645
+ this.releaseViewerState(binding);
226
646
  return publicResource(stopped, this.store.revision);
227
647
  });
228
648
  }
@@ -230,12 +650,18 @@ class BrowserResourceManager extends EventEmitter {
230
650
  async delete(id) {
231
651
  this.requireEnabled();
232
652
  await this.stop(id);
233
- this.requireStored(id);
653
+ const resource = this.requireStored(id);
234
654
  this.store.delete(id);
235
- const profileDir = storageLayout.browserProfileDir(this.configDir, id);
655
+ const sessionId = resource.sessionId || id;
656
+ const profileDir = storageLayout.browserProfileDir(this.configDir, sessionId);
236
657
  const browsersDir = path.resolve(storageLayout.browserResourcesDir(this.configDir));
237
658
  const resourceDir = path.resolve(profileDir, '..');
238
- if (resourceDir.startsWith(`${browsersDir}${path.sep}`) && RESOURCE_ID_RE.test(id)) {
659
+ const sessionStillReferenced = this.store.list().some(candidate => candidate.sessionId === sessionId);
660
+ if (
661
+ !sessionStillReferenced
662
+ && resourceDir.startsWith(`${browsersDir}${path.sep}`)
663
+ && RESOURCE_ID_RE.test(sessionId)
664
+ ) {
239
665
  fs.rmSync(resourceDir, { recursive: true, force: true });
240
666
  }
241
667
  this.emit('deleted', { id, collectionRevision: this.store.revision });
@@ -245,33 +671,33 @@ class BrowserResourceManager extends EventEmitter {
245
671
  navigate(id, url) {
246
672
  this.requireEnabled();
247
673
  const normalized = normalizeUrl(url);
248
- return this.withRuntime(id, async runtime => {
674
+ return this.withRuntime(id, async (runtime, binding) => {
249
675
  const metadata = await runtime.navigate(normalized);
250
- this.updateMetadata(runtime, metadata);
676
+ this.updateMetadata(binding, metadata);
251
677
  return this.get(id);
252
678
  });
253
679
  }
254
680
 
255
681
  goBack(id) {
256
- return this.withRuntime(id, async runtime => {
682
+ return this.withRuntime(id, async (runtime, binding) => {
257
683
  const metadata = await runtime.goBack();
258
- this.updateMetadata(runtime, metadata);
684
+ this.updateMetadata(binding, metadata);
259
685
  return this.get(id);
260
686
  });
261
687
  }
262
688
 
263
689
  goForward(id) {
264
- return this.withRuntime(id, async runtime => {
690
+ return this.withRuntime(id, async (runtime, binding) => {
265
691
  const metadata = await runtime.goForward();
266
- this.updateMetadata(runtime, metadata);
692
+ this.updateMetadata(binding, metadata);
267
693
  return this.get(id);
268
694
  });
269
695
  }
270
696
 
271
697
  reload(id) {
272
- return this.withRuntime(id, async runtime => {
698
+ return this.withRuntime(id, async (runtime, binding) => {
273
699
  const metadata = await runtime.reload();
274
- this.updateMetadata(runtime, metadata);
700
+ this.updateMetadata(binding, metadata);
275
701
  return this.get(id);
276
702
  });
277
703
  }
@@ -286,9 +712,74 @@ class BrowserResourceManager extends EventEmitter {
286
712
  if (kind === 'forward') return this.goForward(id);
287
713
  if (kind === 'reload') return this.reload(id);
288
714
  if (kind === 'click') return this.withRuntime(id, runtime => runtime.click(input));
715
+ if ([
716
+ 'dblclick',
717
+ 'hover',
718
+ 'focus',
719
+ 'check',
720
+ 'uncheck',
721
+ 'scrollintoview',
722
+ 'highlight',
723
+ ].includes(kind)) {
724
+ return this.withRuntime(id, runtime => runtime.elementAction(kind, input));
725
+ }
289
726
  if (kind === 'type') return this.withRuntime(id, runtime => runtime.type(input, false));
290
727
  if (kind === 'fill') return this.withRuntime(id, runtime => runtime.type(input, true));
728
+ if (kind === 'keyboard') return this.withRuntime(id, runtime => runtime.keyboard(input));
291
729
  if (kind === 'press') return this.withRuntime(id, runtime => runtime.press(input));
730
+ if (kind === 'select') return this.withRuntime(id, runtime => runtime.select(input));
731
+ if (kind === 'drag') return this.withRuntime(id, runtime => runtime.drag(input));
732
+ if (kind === 'wait') return this.withRuntime(id, runtime => runtime.waitFor(input));
733
+ if (kind === 'get') return this.withRuntime(id, runtime => runtime.get(input));
734
+ if (kind === 'is') return this.withRuntime(id, runtime => runtime.is(input));
735
+ if (kind === 'find') return this.withRuntime(id, runtime => runtime.find(input));
736
+ if (kind === 'eval') return this.withRuntime(id, runtime => runtime.evaluate(input));
737
+ if (kind === 'console' || kind === 'errors') {
738
+ return this.withRuntime(id, runtime => runtime.debugLog(kind, input));
739
+ }
740
+ if (kind === 'network') return this.withRuntime(id, runtime => runtime.network(input));
741
+ if (kind === 'cookies') return this.withRuntime(id, runtime => runtime.cookies(input));
742
+ if (kind === 'storage') return this.withRuntime(id, runtime => runtime.storage(input));
743
+ if (kind === 'frame') return this.withRuntime(id, runtime => runtime.frame(input));
744
+ if (kind === 'dialog') return this.withRuntime(id, runtime => runtime.dialog(input));
745
+ if (kind === 'upload') {
746
+ const resource = this.requireStored(id);
747
+ const requestedFiles = Array.isArray(input?.files) ? input.files : [];
748
+ if (requestedFiles.length === 0 || requestedFiles.length > MAX_UPLOAD_FILES) {
749
+ throw browserError(`Browser upload requires between 1 and ${MAX_UPLOAD_FILES} files`);
750
+ }
751
+ const files = requestedFiles.map(file => resolveWorkspaceInputFile(resource, file));
752
+ return this.withRuntime(id, runtime => runtime.upload({ ...input, files }));
753
+ }
754
+ if (kind === 'download') {
755
+ const resource = this.requireStored(id);
756
+ const target = resolveWorkspaceOutputFile(resource, input?.path);
757
+ return this.withRuntime(id, async runtime => {
758
+ const resourceDir = path.dirname(storageLayout.browserProfileDir(
759
+ this.configDir,
760
+ resource.sessionId || id,
761
+ ));
762
+ const downloadDir = path.join(resourceDir, 'downloads');
763
+ fs.mkdirSync(downloadDir, { recursive: true, mode: 0o700 });
764
+ const temporaryPath = path.join(
765
+ downloadDir,
766
+ `${crypto.randomUUID()}-${path.basename(target)}`,
767
+ );
768
+ try {
769
+ await runtime.download({ ...input, outputPath: temporaryPath });
770
+ const stat = fs.statSync(temporaryPath);
771
+ if (!stat.isFile()) throw browserError('Browser download did not produce a regular file');
772
+ fs.copyFileSync(temporaryPath, target, fs.constants.COPYFILE_EXCL);
773
+ return {
774
+ ok: true,
775
+ path: path.relative(resource.workspace, target) || path.basename(target),
776
+ size: stat.size,
777
+ };
778
+ } finally {
779
+ fs.rmSync(temporaryPath, { force: true });
780
+ }
781
+ });
782
+ }
292
783
  if (kind === 'scroll') return this.withRuntime(id, async runtime => {
293
784
  await runtime.wheel(input);
294
785
  return { ok: true };
@@ -299,15 +790,19 @@ class BrowserResourceManager extends EventEmitter {
299
790
  attachViewer(id, ws) {
300
791
  this.requireEnabled();
301
792
  const resource = this.requireStored(id);
302
- const runtime = this.runtimes.get(id);
793
+ const binding = this.runtimes.get(id);
303
794
  ws.send(JSON.stringify({
304
795
  type: 'browser-state',
305
796
  resource: publicResource(resource, this.store.revision),
306
797
  }));
307
- if (!runtime || resource.status !== 'running') return () => {};
308
- if (!runtime.viewers) runtime.viewers = new Set();
309
- runtime.viewers.add(ws);
310
- if (runtime.latestFrame) ws.send(JSON.stringify(runtime.latestFrame));
798
+ if (!binding || resource.status !== 'running') return () => {};
799
+ binding.viewers.add(ws);
800
+ if (binding.latestFrame) ws.send(JSON.stringify(binding.latestFrame));
801
+ void this.withRuntime(id, () => {}).catch(error => {
802
+ if (ws.readyState === 1) {
803
+ ws.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser tab failed' }));
804
+ }
805
+ });
311
806
  const onMessage = raw => {
312
807
  let message;
313
808
  try {
@@ -315,7 +810,10 @@ class BrowserResourceManager extends EventEmitter {
315
810
  } catch {
316
811
  return;
317
812
  }
318
- void this.handleViewerMessage(runtime, message).catch(error => {
813
+ const operation = message.type === 'resize'
814
+ ? this.scheduleViewerResize(binding, ws, message)
815
+ : this.handleViewerMessage(binding, ws, message);
816
+ void Promise.resolve(operation).catch(error => {
319
817
  if (ws.readyState === 1) {
320
818
  ws.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser input failed' }));
321
819
  }
@@ -323,29 +821,132 @@ class BrowserResourceManager extends EventEmitter {
323
821
  };
324
822
  ws.on('message', onMessage);
325
823
  const detach = () => {
326
- runtime.viewers.delete(ws);
824
+ binding.viewers.delete(ws);
825
+ binding.viewerGeometries.delete(ws);
826
+ if (binding.viewerViewportOwner === ws) {
827
+ binding.viewerViewportOwner = binding.viewers.values().next().value || null;
828
+ if (binding.viewerViewportOwner) {
829
+ const geometry = binding.viewerGeometries.get(binding.viewerViewportOwner);
830
+ if (geometry) {
831
+ void this.scheduleViewerResize(binding, binding.viewerViewportOwner, geometry).catch(() => {});
832
+ }
833
+ } else {
834
+ this.clearViewerResize(binding);
835
+ }
836
+ }
327
837
  ws.off('message', onMessage);
328
838
  };
329
839
  ws.once('close', detach);
330
840
  return detach;
331
841
  }
332
842
 
333
- handleViewerMessage(runtime, message) {
334
- const next = (runtime.actionChain || Promise.resolve())
843
+ handleViewerMessage(binding, viewer, message) {
844
+ const resource = this.requireStored(binding.id);
845
+ if (
846
+ resource.status !== 'running'
847
+ || message.generation !== binding.generation
848
+ || this.runtimes.get(binding.id) !== binding
849
+ ) {
850
+ return Promise.reject(browserError(
851
+ 'Browser Viewer input is no longer admitted',
852
+ 409,
853
+ 'BROWSER_NOT_RUNNING',
854
+ ));
855
+ }
856
+ const { session } = binding;
857
+ const next = (session.actionChain || Promise.resolve())
335
858
  .catch(() => {})
336
- .then(() => this.performViewerMessage(runtime, message));
337
- runtime.actionChain = next;
859
+ .then(async () => {
860
+ await this.activateBinding(binding);
861
+ return this.performViewerMessage(binding, viewer, message);
862
+ });
863
+ session.actionChain = next;
338
864
  return next;
339
865
  }
340
866
 
341
- async performViewerMessage(runtime, message) {
342
- if (message.generation !== runtime.generation || this.runtimes.get(runtime.id) !== runtime) {
867
+ scheduleViewerResize(binding, viewer, message) {
868
+ const resource = this.requireStored(binding.id);
869
+ if (
870
+ resource.status !== 'running'
871
+ || message.generation !== binding.generation
872
+ || this.runtimes.get(binding.id) !== binding
873
+ ) {
874
+ return Promise.reject(browserError(
875
+ 'Browser Viewer resize is no longer admitted',
876
+ 409,
877
+ 'BROWSER_NOT_RUNNING',
878
+ ));
879
+ }
880
+ const width = Math.round(Number(message.width));
881
+ const height = Math.round(Number(message.height));
882
+ const requestedDeviceScaleFactor = Number(message.deviceScaleFactor);
883
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
884
+ return Promise.reject(browserError('Browser Viewer size is invalid'));
885
+ }
886
+ const deviceScaleFactor = Number.isFinite(requestedDeviceScaleFactor)
887
+ ? Math.max(1, Math.min(2, requestedDeviceScaleFactor))
888
+ : 1;
889
+ const geometry = {
890
+ type: 'resize',
891
+ generation: message.generation,
892
+ width,
893
+ height,
894
+ deviceScaleFactor,
895
+ };
896
+ binding.viewerGeometries.set(viewer, geometry);
897
+ if (
898
+ message.claim === true
899
+ || !binding.viewerViewportOwner
900
+ || !binding.viewers.has(binding.viewerViewportOwner)
901
+ ) {
902
+ binding.viewerViewportOwner = viewer;
903
+ }
904
+ if (binding.viewerViewportOwner !== viewer) return Promise.resolve();
905
+ binding.pendingViewerResize = { viewer, geometry };
906
+ if (binding.viewerResizeTimer) this.cancelTimeout(binding.viewerResizeTimer);
907
+ binding.viewerResizeTimer = this.scheduleTimeout(() => {
908
+ binding.viewerResizeTimer = null;
909
+ const pending = binding.pendingViewerResize;
910
+ binding.pendingViewerResize = null;
911
+ if (!pending || binding.viewerViewportOwner !== pending.viewer) return;
912
+ void this.handleViewerMessage(binding, pending.viewer, pending.geometry).catch(error => {
913
+ if (pending.viewer.readyState === 1) {
914
+ pending.viewer.send(JSON.stringify({
915
+ type: 'browser-error',
916
+ message: error?.message || 'Browser resize failed',
917
+ }));
918
+ }
919
+ });
920
+ }, VIEWER_RESIZE_SETTLE_MS);
921
+ binding.viewerResizeTimer.unref?.();
922
+ return Promise.resolve();
923
+ }
924
+
925
+ clearViewerResize(runtime) {
926
+ if (runtime.viewerResizeTimer) this.cancelTimeout(runtime.viewerResizeTimer);
927
+ runtime.viewerResizeTimer = null;
928
+ runtime.pendingViewerResize = null;
929
+ }
930
+
931
+ releaseViewerState(runtime) {
932
+ this.clearViewerResize(runtime);
933
+ runtime.viewerGeometries?.clear?.();
934
+ runtime.viewerViewportOwner = null;
935
+ runtime.viewers?.clear?.();
936
+ }
937
+
938
+ async performViewerMessage(binding, viewer, message) {
939
+ if (message.generation !== binding.generation || this.runtimes.get(binding.id) !== binding) {
343
940
  throw browserError('Browser Viewer generation is stale', 409, 'BROWSER_STALE_GENERATION');
344
941
  }
942
+ const runtime = binding.session.runtime;
345
943
  if (message.type === 'resize') {
346
- // A Browser resource owns one authoritative viewport. Viewer layout is
347
- // presentation-only: allowing each attached desktop or mobile Viewer to
348
- // resize the page makes concurrent viewers fight over shared page state.
944
+ if (binding.viewerViewportOwner !== viewer) return;
945
+ await runtime.resize({
946
+ width: message.width,
947
+ height: message.height,
948
+ deviceScaleFactor: message.deviceScaleFactor,
949
+ });
349
950
  return;
350
951
  }
351
952
  if (message.type === 'pointer') {
@@ -367,20 +968,27 @@ class BrowserResourceManager extends EventEmitter {
367
968
 
368
969
  async dispose() {
369
970
  this.disposed = true;
370
- const runtimes = [...this.runtimes.values()];
371
- const results = await Promise.allSettled(runtimes.map(runtime => runtime.close()));
971
+ const sessions = [...this.sessions.values()];
972
+ const results = await Promise.allSettled(sessions.map(session => session.runtime.close()));
372
973
  const failures = results
373
974
  .filter(result => result.status === 'rejected')
374
975
  .map(result => result.reason?.message || String(result.reason));
375
976
  if (failures.length > 0) {
376
977
  throw new Error(`Browser runtime cleanup failed: ${failures.join('; ')}`);
377
978
  }
979
+ for (const binding of this.runtimes.values()) this.releaseViewerState(binding);
378
980
  this.runtimes.clear();
981
+ this.sessions.clear();
379
982
  }
380
983
 
381
984
  async stopAll() {
382
985
  const ids = [...this.runtimes.keys()];
383
- const results = await Promise.allSettled(ids.map(id => this.stop(id)));
986
+ const results = [];
987
+ for (const id of ids) {
988
+ results.push(await Promise.resolve(this.stop(id))
989
+ .then(value => ({ status: 'fulfilled', value }))
990
+ .catch(reason => ({ status: 'rejected', reason })));
991
+ }
384
992
  const failures = results
385
993
  .filter(result => result.status === 'rejected')
386
994
  .map(result => result.reason?.message || String(result.reason));
@@ -401,9 +1009,10 @@ class BrowserResourceManager extends EventEmitter {
401
1009
 
402
1010
  requireAvailable() {
403
1011
  this.requireEnabled();
404
- if (!this.discoverExecutable()) {
1012
+ const executable = this.runtimeCapability;
1013
+ if (!executable || executable.error) {
405
1014
  throw browserError(
406
- 'Install a Chromium-based browser to use the system Browser in Farming',
1015
+ executable?.error || 'Install agent-browser and a Chromium-based browser, or configure a loopback external CDP endpoint',
407
1016
  503,
408
1017
  'BROWSER_EXECUTABLE_NOT_FOUND',
409
1018
  );
@@ -428,91 +1037,266 @@ class BrowserResourceManager extends EventEmitter {
428
1037
  });
429
1038
  }
430
1039
 
1040
+ createBinding(session, resource) {
1041
+ return {
1042
+ id: resource.id,
1043
+ generation: resource.generation,
1044
+ session,
1045
+ tabId: resource.tabId || '',
1046
+ viewers: new Set(),
1047
+ viewerGeometries: new Map(),
1048
+ viewerViewportOwner: null,
1049
+ viewerResizeTimer: null,
1050
+ pendingViewerResize: null,
1051
+ latestFrame: null,
1052
+ };
1053
+ }
1054
+
1055
+ async activateBinding(binding) {
1056
+ const { session } = binding;
1057
+ if (!binding.tabId) throw browserError('Browser tab is unavailable', 409, 'BROWSER_TAB_UNAVAILABLE');
1058
+ if (
1059
+ session.runtime.activeTabId !== binding.tabId
1060
+ || session.runtime.streamTabId !== binding.tabId
1061
+ ) {
1062
+ await session.runtime.switchTab(binding.tabId);
1063
+ }
1064
+ session.activeResourceId = binding.id;
1065
+ }
1066
+
431
1067
  withRuntime(id, operation) {
432
- const runtime = this.runtimes.get(id);
1068
+ const binding = this.runtimes.get(id);
433
1069
  const resource = this.requireStored(id);
434
- if (!runtime || resource.status !== 'running') {
1070
+ if (!binding || resource.status !== 'running') {
435
1071
  throw browserError('Browser is not running', 409, 'BROWSER_NOT_RUNNING');
436
1072
  }
437
- const next = (runtime.actionChain || Promise.resolve())
1073
+ const { session } = binding;
1074
+ const next = (session.actionChain || Promise.resolve())
438
1075
  .catch(() => {})
439
- .then(() => operation(runtime));
440
- runtime.actionChain = next;
1076
+ .then(async () => {
1077
+ await this.activateBinding(binding);
1078
+ return operation(session.runtime, binding);
1079
+ });
1080
+ session.actionChain = next;
441
1081
  return next;
442
1082
  }
443
1083
 
444
- bindRuntime(runtime) {
1084
+ bindSession(session) {
1085
+ const { runtime } = session;
1086
+ runtime.on('process-identity', processIdentity => {
1087
+ const owner = this.store.get(session.processOwnerResourceId);
1088
+ if (
1089
+ this.sessions.get(session.id) !== session
1090
+ || !owner
1091
+ || owner.sessionId && owner.sessionId !== session.id
1092
+ ) return;
1093
+ const next = this.store.update(owner.id, { processIdentity });
1094
+ this.emitResource(next);
1095
+ });
445
1096
  runtime.on('frame', frame => {
446
- runtime.latestFrame = frame;
447
- for (const viewer of runtime.viewers || []) {
1097
+ const binding = [...session.bindings.values()]
1098
+ .find(candidate => candidate.tabId === runtime.streamTabId)
1099
+ || session.bindings.get(session.activeResourceId);
1100
+ if (!binding) return;
1101
+ const resourceFrame = {
1102
+ ...frame,
1103
+ generation: binding.generation,
1104
+ };
1105
+ binding.latestFrame = resourceFrame;
1106
+ for (const viewer of binding.viewers) {
448
1107
  if (viewer.readyState === 1 && (Number(viewer.bufferedAmount) || 0) <= MAX_VIEWER_BUFFER_BYTES) {
449
- viewer.send(JSON.stringify(frame));
1108
+ viewer.send(JSON.stringify(resourceFrame));
450
1109
  }
451
1110
  }
452
1111
  });
453
- runtime.on('metadata', metadata => this.updateMetadata(runtime, metadata));
1112
+ runtime.on('metadata', metadata => {
1113
+ const binding = [...session.bindings.values()]
1114
+ .find(candidate => candidate.tabId === runtime.activeTabId)
1115
+ || session.bindings.get(session.activeResourceId);
1116
+ if (binding) this.updateMetadata(binding, metadata);
1117
+ });
1118
+ runtime.on('tabs', event => {
1119
+ if (session.initializing || session.closing) return;
1120
+ const next = (session.actionChain || Promise.resolve())
1121
+ .catch(() => {})
1122
+ .then(() => this.reconcileTabs(session, event));
1123
+ session.actionChain = next;
1124
+ });
454
1125
  runtime.on('error', error => {
455
- for (const viewer of runtime.viewers || []) {
456
- if (viewer.readyState === 1) {
457
- viewer.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser runtime failed' }));
1126
+ for (const binding of session.bindings.values()) {
1127
+ for (const viewer of binding.viewers) {
1128
+ if (viewer.readyState === 1) {
1129
+ viewer.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser runtime failed' }));
1130
+ }
458
1131
  }
459
1132
  }
460
1133
  });
461
1134
  runtime.once('exit', message => {
462
- void this.handleRuntimeExit(runtime, message);
1135
+ void this.handleRuntimeExit(session, message);
463
1136
  });
464
1137
  }
465
1138
 
466
- updateMetadata(runtime, metadata) {
467
- const current = this.store.get(runtime.id);
468
- if (!current || current.generation !== runtime.generation || !metadata) return;
469
- const next = this.store.update(runtime.id, {
1139
+ async reconcileTabs(session, event) {
1140
+ if (this.sessions.get(session.id) !== session || session.closing) return;
1141
+ const tabs = Array.isArray(event?.tabs) ? event.tabs.filter(tab => tab.type === 'page') : [];
1142
+ const newlyObservedTabIds = new Set(
1143
+ Array.isArray(event?.newTabIds) ? event.newTabIds.map(String) : [],
1144
+ );
1145
+ const byTabId = new Map([...session.bindings.values()].map(binding => [binding.tabId, binding]));
1146
+ const opener = session.bindings.get(session.activeResourceId) || null;
1147
+ const opened = [];
1148
+
1149
+ for (const tab of tabs) {
1150
+ let binding = byTabId.get(tab.tabId);
1151
+ if (
1152
+ !binding
1153
+ && newlyObservedTabIds.has(tab.tabId)
1154
+ && (!session.runtime.externalCdpUrl || event.popupAdmitted)
1155
+ ) {
1156
+ session.runtime.ownedTabIds.add(tab.tabId);
1157
+ const created = this.store.createRunningTab({
1158
+ projectRootId: opener?.session.projectRootId || session.projectRootId,
1159
+ workspace: this.store.get(opener?.id)?.workspace
1160
+ || this.store.list().find(resource => resource.sessionId === session.id)?.workspace,
1161
+ name: tabResourceName(tab),
1162
+ url: tab.url,
1163
+ title: tab.title,
1164
+ browserKind: session.browserKind,
1165
+ sessionId: session.id,
1166
+ sessionGeneration: session.generation,
1167
+ tabId: tab.tabId,
1168
+ });
1169
+ binding = this.createBinding(session, created);
1170
+ session.bindings.set(created.id, binding);
1171
+ this.runtimes.set(created.id, binding);
1172
+ byTabId.set(tab.tabId, binding);
1173
+ this.emitResource(created);
1174
+ opened.push(publicResource(created, this.store.revision));
1175
+ }
1176
+ if (!binding) continue;
1177
+ const current = this.store.get(binding.id);
1178
+ if (!current || current.status !== 'running') continue;
1179
+ if (current.url !== tab.url || current.title !== tab.title) {
1180
+ const updated = this.store.update(binding.id, {
1181
+ url: tab.url || current.url,
1182
+ title: tab.title || '',
1183
+ ...(current.autoName ? { name: tabResourceName(tab) } : {}),
1184
+ });
1185
+ this.emitResource(updated);
1186
+ this.broadcastRuntimeState(binding);
1187
+ }
1188
+ }
1189
+
1190
+ const liveTabIds = new Set(tabs.map(tab => tab.tabId));
1191
+ for (const binding of [...session.bindings.values()]) {
1192
+ if (liveTabIds.has(binding.tabId)) continue;
1193
+ const current = this.store.get(binding.id);
1194
+ if (!current || current.status === 'stopping') continue;
1195
+ session.bindings.delete(binding.id);
1196
+ this.runtimes.delete(binding.id);
1197
+ if (session.processOwnerResourceId === binding.id && session.bindings.size > 0) {
1198
+ const nextOwner = session.bindings.values().next().value;
1199
+ session.processOwnerResourceId = nextOwner.id;
1200
+ const transferred = this.store.update(nextOwner.id, {
1201
+ processIdentity: current.processIdentity,
1202
+ });
1203
+ this.emitResource(transferred);
1204
+ this.broadcastRuntimeState(nextOwner);
1205
+ }
1206
+ const stopped = this.store.update(binding.id, {
1207
+ status: 'stopped',
1208
+ tabId: '',
1209
+ processIdentity: null,
1210
+ error: '',
1211
+ });
1212
+ this.emitResource(stopped);
1213
+ this.broadcastRuntimeState(binding);
1214
+ this.releaseViewerState(binding);
1215
+ }
1216
+
1217
+ const activeTab = tabs.find(tab => tab.active);
1218
+ const activeBinding = activeTab ? byTabId.get(activeTab.tabId) : null;
1219
+ if (activeBinding) {
1220
+ session.activeResourceId = activeBinding.id;
1221
+ if (session.runtime.streamTabId !== activeBinding.tabId) {
1222
+ await session.runtime.switchTab(activeBinding.tabId);
1223
+ }
1224
+ }
1225
+ if (opened.length > 0 && opener) {
1226
+ const message = JSON.stringify({
1227
+ type: 'browser-tab-opened',
1228
+ resource: opened.at(-1),
1229
+ });
1230
+ for (const viewer of opener.viewers) {
1231
+ if (viewer.readyState === 1) viewer.send(message);
1232
+ }
1233
+ }
1234
+ }
1235
+
1236
+ updateMetadata(binding, metadata) {
1237
+ const current = this.store.get(binding.id);
1238
+ if (!current || current.generation !== binding.generation || !metadata) return;
1239
+ const next = this.store.update(binding.id, {
470
1240
  url: String(metadata.url || current.url),
471
1241
  title: String(metadata.title || ''),
472
1242
  });
473
1243
  this.emitResource(next);
474
- this.broadcastRuntimeState(runtime);
1244
+ this.broadcastRuntimeState(binding);
475
1245
  }
476
1246
 
477
1247
  emitResource(resource) {
478
1248
  this.emit('resource', publicResource(resource, this.store.revision));
479
1249
  }
480
1250
 
481
- broadcastRuntimeState(runtime) {
482
- const resource = this.store.get(runtime.id);
1251
+ broadcastRuntimeState(binding) {
1252
+ const resource = this.store.get(binding.id);
483
1253
  if (!resource) return;
484
1254
  const message = JSON.stringify({
485
1255
  type: 'browser-state',
486
1256
  resource: publicResource(resource, this.store.revision),
487
1257
  });
488
- for (const viewer of runtime.viewers || []) {
1258
+ for (const viewer of binding.viewers || []) {
489
1259
  if (viewer.readyState === 1) viewer.send(message);
490
1260
  }
491
1261
  }
492
1262
 
493
- async handleRuntimeExit(runtime, message) {
494
- const current = this.store.get(runtime.id);
495
- if (
496
- this.runtimes.get(runtime.id) !== runtime
497
- || !current
498
- || current.generation !== runtime.generation
499
- ) return;
500
- const failed = this.store.update(runtime.id, {
501
- status: 'failed',
502
- error: message || 'System browser exited',
503
- });
504
- this.emitResource(failed);
505
- this.broadcastRuntimeState(runtime);
506
- try {
507
- await runtime.close();
508
- if (this.runtimes.get(runtime.id) === runtime) this.runtimes.delete(runtime.id);
509
- } catch (error) {
510
- const cleanupFailed = this.store.update(runtime.id, {
1263
+ async handleRuntimeExit(session, message) {
1264
+ if (this.sessions.get(session.id) !== session) return;
1265
+ const failedBindings = [...session.bindings.values()];
1266
+ for (const binding of failedBindings) {
1267
+ const current = this.store.get(binding.id);
1268
+ if (!current) continue;
1269
+ const failed = this.store.update(binding.id, {
511
1270
  status: 'failed',
512
- error: `${failed.error}; cleanup failed: ${error?.message || error}`,
1271
+ error: current.browserKind === 'external-cdp'
1272
+ ? 'External Browser connection exited'
1273
+ : message || 'Browser connection exited',
513
1274
  });
514
- this.emitResource(cleanupFailed);
515
- this.broadcastRuntimeState(runtime);
1275
+ this.emitResource(failed);
1276
+ this.broadcastRuntimeState(binding);
1277
+ }
1278
+ try {
1279
+ await session.runtime.close();
1280
+ this.sessions.delete(session.id);
1281
+ for (const binding of failedBindings) {
1282
+ this.runtimes.delete(binding.id);
1283
+ this.releaseViewerState(binding);
1284
+ const cleaned = this.store.update(binding.id, { processIdentity: null });
1285
+ if (cleaned) this.emitResource(cleaned);
1286
+ }
1287
+ } catch (error) {
1288
+ for (const binding of failedBindings) {
1289
+ const current = this.store.get(binding.id);
1290
+ if (!current) continue;
1291
+ const cleanupFailed = this.store.update(binding.id, {
1292
+ status: 'failed',
1293
+ error: current.browserKind === 'external-cdp'
1294
+ ? `${current.error}; target cleanup failed`
1295
+ : `${current.error}; cleanup failed: ${error?.message || error}`,
1296
+ });
1297
+ this.emitResource(cleanupFailed);
1298
+ this.broadcastRuntimeState(binding);
1299
+ }
516
1300
  }
517
1301
  }
518
1302
  }
@@ -520,5 +1304,6 @@ class BrowserResourceManager extends EventEmitter {
520
1304
  module.exports = {
521
1305
  BrowserResourceManager,
522
1306
  browserError,
1307
+ externalBrowserFailure,
523
1308
  normalizeUrl,
524
1309
  };