farming-code 2.2.28 → 2.2.30

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