@phnx-labs/agents-cli 1.15.0 → 1.17.0

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 (111) hide show
  1. package/CHANGELOG.md +143 -39
  2. package/README.md +6 -6
  3. package/dist/commands/alias.js +2 -2
  4. package/dist/commands/browser-picker.d.ts +21 -0
  5. package/dist/commands/browser-picker.js +114 -0
  6. package/dist/commands/browser.js +793 -83
  7. package/dist/commands/cloud.js +8 -0
  8. package/dist/commands/commands.js +72 -22
  9. package/dist/commands/daemon.js +2 -2
  10. package/dist/commands/exec.js +70 -1
  11. package/dist/commands/hooks.js +71 -26
  12. package/dist/commands/mcp.js +81 -39
  13. package/dist/commands/plugins.js +224 -17
  14. package/dist/commands/prune.js +29 -1
  15. package/dist/commands/pull.js +3 -3
  16. package/dist/commands/repo.js +1 -1
  17. package/dist/commands/routines.js +2 -2
  18. package/dist/commands/secrets.js +154 -20
  19. package/dist/commands/sessions.js +62 -19
  20. package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
  21. package/dist/commands/{init.js → setup.js} +22 -21
  22. package/dist/commands/skills.js +60 -19
  23. package/dist/commands/subagents.js +41 -13
  24. package/dist/commands/utils.d.ts +16 -0
  25. package/dist/commands/utils.js +32 -0
  26. package/dist/commands/view.js +78 -20
  27. package/dist/commands/workflows.d.ts +10 -0
  28. package/dist/commands/workflows.js +457 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +48 -36
  31. package/dist/lib/agents.js +2 -2
  32. package/dist/lib/auto-pull-worker.js +2 -3
  33. package/dist/lib/auto-pull.js +2 -2
  34. package/dist/lib/browser/cdp.d.ts +7 -1
  35. package/dist/lib/browser/cdp.js +32 -1
  36. package/dist/lib/browser/chrome.d.ts +10 -0
  37. package/dist/lib/browser/chrome.js +41 -3
  38. package/dist/lib/browser/devices.d.ts +4 -0
  39. package/dist/lib/browser/devices.js +27 -0
  40. package/dist/lib/browser/drivers/local.js +22 -6
  41. package/dist/lib/browser/drivers/ssh.js +9 -2
  42. package/dist/lib/browser/input.d.ts +1 -0
  43. package/dist/lib/browser/input.js +3 -0
  44. package/dist/lib/browser/ipc.js +158 -23
  45. package/dist/lib/browser/profiles.d.ts +10 -2
  46. package/dist/lib/browser/profiles.js +122 -37
  47. package/dist/lib/browser/service.d.ts +91 -13
  48. package/dist/lib/browser/service.js +767 -132
  49. package/dist/lib/browser/types.d.ts +91 -3
  50. package/dist/lib/browser/types.js +16 -0
  51. package/dist/lib/cloud/rush.d.ts +28 -1
  52. package/dist/lib/cloud/rush.js +69 -14
  53. package/dist/lib/cloud/store.js +2 -2
  54. package/dist/lib/commands.d.ts +1 -15
  55. package/dist/lib/commands.js +11 -7
  56. package/dist/lib/daemon.js +2 -3
  57. package/dist/lib/doctor-diff.js +4 -4
  58. package/dist/lib/events.js +2 -2
  59. package/dist/lib/hooks.d.ts +11 -7
  60. package/dist/lib/hooks.js +138 -49
  61. package/dist/lib/migrate.d.ts +1 -1
  62. package/dist/lib/migrate.js +1237 -22
  63. package/dist/lib/models.js +2 -2
  64. package/dist/lib/permissions.d.ts +8 -66
  65. package/dist/lib/permissions.js +18 -18
  66. package/dist/lib/plugins.d.ts +94 -24
  67. package/dist/lib/plugins.js +702 -123
  68. package/dist/lib/pty-server.js +9 -10
  69. package/dist/lib/resource-patterns.d.ts +41 -0
  70. package/dist/lib/resource-patterns.js +82 -0
  71. package/dist/lib/resources/hooks.d.ts +5 -1
  72. package/dist/lib/resources/hooks.js +21 -4
  73. package/dist/lib/resources/index.d.ts +17 -0
  74. package/dist/lib/resources/index.js +7 -0
  75. package/dist/lib/resources/types.d.ts +1 -1
  76. package/dist/lib/resources/workflows.d.ts +24 -0
  77. package/dist/lib/resources/workflows.js +110 -0
  78. package/dist/lib/resources.d.ts +6 -1
  79. package/dist/lib/resources.js +12 -2
  80. package/dist/lib/rotate.js +3 -4
  81. package/dist/lib/session/active.d.ts +3 -0
  82. package/dist/lib/session/active.js +92 -6
  83. package/dist/lib/session/cloud.js +2 -2
  84. package/dist/lib/session/db.d.ts +18 -0
  85. package/dist/lib/session/db.js +109 -5
  86. package/dist/lib/session/discover.d.ts +6 -0
  87. package/dist/lib/session/discover.js +55 -29
  88. package/dist/lib/session/team-filter.js +2 -2
  89. package/dist/lib/shims.d.ts +4 -52
  90. package/dist/lib/shims.js +23 -15
  91. package/dist/lib/skills.js +6 -2
  92. package/dist/lib/sqlite.js +10 -4
  93. package/dist/lib/state.d.ts +101 -16
  94. package/dist/lib/state.js +179 -31
  95. package/dist/lib/subagents.d.ts +28 -0
  96. package/dist/lib/subagents.js +98 -1
  97. package/dist/lib/sync-manifest.d.ts +1 -1
  98. package/dist/lib/sync-manifest.js +3 -3
  99. package/dist/lib/teams/persistence.js +15 -5
  100. package/dist/lib/teams/registry.js +2 -2
  101. package/dist/lib/types.d.ts +75 -17
  102. package/dist/lib/types.js +3 -3
  103. package/dist/lib/usage.js +2 -2
  104. package/dist/lib/versions.d.ts +3 -0
  105. package/dist/lib/versions.js +158 -47
  106. package/dist/lib/workflows.d.ts +79 -0
  107. package/dist/lib/workflows.js +233 -0
  108. package/package.json +1 -5
  109. package/scripts/postinstall.js +60 -59
  110. package/dist/commands/fork.d.ts +0 -10
  111. package/dist/commands/fork.js +0 -146
@@ -1,26 +1,30 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { CDPClient, discoverBrowserWsUrl } from './cdp.js';
4
- import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir } from './profiles.js';
3
+ import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from './cdp.js';
4
+ import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, } from './profiles.js';
5
5
  import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
6
6
  import { connectLocal } from './drivers/local.js';
7
7
  import { connectSSH } from './drivers/ssh.js';
8
- import { generateTaskId, isValidTaskId, } from './types.js';
8
+ import { generateTaskId, generateShortId, generateFunName, } from './types.js';
9
9
  import { getRefs, resolveRefToCoords } from './refs.js';
10
- import { clickAtCoords, hoverAtCoords, typeText, pressKey, focusNode } from './input.js';
10
+ import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
11
11
  import { emit } from '../events.js';
12
12
  export class BrowserService {
13
13
  connections = new Map();
14
14
  forkingProfiles = new Set();
15
- async start(profileName, taskId) {
15
+ // Per-task storage for console, errors, network, downloads
16
+ consoleLogs = new Map();
17
+ pageErrors = new Map();
18
+ networkRequests = new Map();
19
+ pendingDownloads = new Map();
20
+ enabledSessions = new Map(); // sessionId -> enabled domains
21
+ async start(profileName, opts = {}) {
16
22
  const profile = await getProfile(profileName);
17
23
  if (!profile) {
18
24
  throw new Error(`Profile "${profileName}" not found`);
19
25
  }
20
- const finalTaskId = taskId || generateTaskId();
21
- if (!isValidTaskId(finalTaskId)) {
22
- throw new Error(`Invalid task ID "${finalTaskId}". Must be lowercase alphanumeric with hyphens.`);
23
- }
26
+ const taskName = opts.taskName || generateFunName();
27
+ const taskId = generateTaskId();
24
28
  let conn = this.connections.get(profileName);
25
29
  let effectiveProfileName = profileName;
26
30
  if (conn && conn.electron && conn.tasks.size > 0) {
@@ -53,46 +57,102 @@ export class BrowserService {
53
57
  conn = await this.connectProfile(profile);
54
58
  this.connections.set(profileName, conn);
55
59
  }
56
- if (conn.tasks.has(finalTaskId)) {
57
- const task = conn.tasks.get(finalTaskId);
58
- return { task: finalTaskId, windowTargetId: task.windowTargetId };
59
- }
60
- const { windowTargetId } = await this.createTaskWindow(conn, finalTaskId);
61
60
  const task = {
62
- id: finalTaskId,
61
+ id: taskId,
62
+ name: taskName,
63
63
  profile: effectiveProfileName,
64
- windowTargetId,
65
- tabIds: conn.electron && windowTargetId ? [windowTargetId] : [],
64
+ tabs: {},
65
+ currentTabId: undefined,
66
66
  createdAt: Date.now(),
67
67
  pid: conn.pid,
68
68
  };
69
- conn.tasks.set(finalTaskId, task);
69
+ // For Electron, get the existing window as the tab
70
+ if (conn.electron) {
71
+ const windowId = await this.getOrCreateWindow(conn);
72
+ if (windowId) {
73
+ const shortId = generateShortId();
74
+ task.tabs[shortId] = windowId;
75
+ task.currentTabId = shortId;
76
+ }
77
+ }
78
+ conn.tasks.set(taskName, task);
70
79
  await this.saveTaskState(effectiveProfileName, conn.tasks);
71
- emit('browser.launch', { profile: effectiveProfileName, task: finalTaskId, pid: conn.pid });
72
- return { task: finalTaskId, windowTargetId };
80
+ emit('browser.launch', { profile: effectiveProfileName, task: taskName, pid: conn.pid });
81
+ // If URL provided, create tab directly (no about:blank)
82
+ let tabId;
83
+ if (opts.url && !conn.electron) {
84
+ const result = (await conn.cdp.send('Target.createTarget', {
85
+ url: opts.url,
86
+ }));
87
+ const shortId = generateShortId();
88
+ task.tabs[shortId] = result.targetId;
89
+ task.currentTabId = shortId;
90
+ this.invalidateTargetCache(conn);
91
+ await this.saveTaskState(effectiveProfileName, conn.tasks);
92
+ tabId = shortId;
93
+ }
94
+ else if (opts.url && conn.electron) {
95
+ const result = await this.navigate(taskName, opts.url, effectiveProfileName);
96
+ tabId = result.tabId;
97
+ }
98
+ return { task: taskId, name: taskName, tabId };
99
+ }
100
+ /**
101
+ * Launch (or attach to) the profile's browser without creating a task. Used by
102
+ * `agents browser profiles launch <name>` so users can warm up the browser —
103
+ * including the first-run onboarding flow — before any automation starts.
104
+ */
105
+ async launchProfile(profileName) {
106
+ const profile = await getProfile(profileName);
107
+ if (!profile) {
108
+ throw new Error(`Profile "${profileName}" not found`);
109
+ }
110
+ let conn = this.connections.get(profileName);
111
+ if (!conn) {
112
+ conn = await this.connectProfile(profile);
113
+ this.connections.set(profileName, conn);
114
+ }
115
+ emit('browser.launch', { profile: profileName, task: '', pid: conn.pid });
116
+ return { port: conn.port, pid: conn.pid };
73
117
  }
74
- async stop(taskId) {
118
+ async stop(taskName) {
75
119
  for (const [profileName, conn] of this.connections) {
76
- const task = conn.tasks.get(taskId);
120
+ const task = conn.tasks.get(taskName);
77
121
  if (task) {
78
- await Promise.all(task.tabIds.map((tabId) => conn.cdp.send('Target.closeTarget', { targetId: tabId }).catch(() => {
122
+ // Get domains from tabs before closing (for history)
123
+ const domains = new Set();
124
+ try {
125
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
126
+ for (const cdpId of Object.values(task.tabs)) {
127
+ const target = targetInfos.find((t) => t.targetId === cdpId);
128
+ if (target?.url) {
129
+ try {
130
+ const domain = new URL(target.url).hostname.replace(/^www\./, '');
131
+ if (domain && domain !== 'blank')
132
+ domains.add(domain);
133
+ }
134
+ catch {
135
+ // invalid URL
136
+ }
137
+ }
138
+ }
139
+ }
140
+ catch {
141
+ // CDP not responding
142
+ }
143
+ // Save to history before closing
144
+ await this.saveToHistory(task, Array.from(domains));
145
+ // Close task's tabs (not the window - it's shared)
146
+ await Promise.all(Object.values(task.tabs).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
79
147
  // Tab already closed
80
148
  })));
81
- for (const tabId of task.tabIds) {
82
- conn.sessionCache.delete(tabId);
149
+ for (const cdpId of Object.values(task.tabs)) {
150
+ conn.sessionCache.delete(cdpId);
83
151
  }
84
152
  this.invalidateTargetCache(conn);
85
- if (task.windowTargetId) {
86
- try {
87
- await conn.cdp.send('Target.closeTarget', { targetId: task.windowTargetId });
88
- }
89
- catch {
90
- // Window already closed
91
- }
92
- }
93
- conn.tasks.delete(taskId);
153
+ conn.tasks.delete(taskName);
94
154
  await this.saveTaskState(profileName, conn.tasks);
95
- emit('browser.close', { profile: profileName, task: taskId });
155
+ emit('browser.close', { profile: profileName, task: taskName });
96
156
  if (conn.forkedFrom && conn.tasks.size === 0) {
97
157
  conn.cdp.close();
98
158
  killChrome(conn.pid);
@@ -103,6 +163,9 @@ export class BrowserService {
103
163
  }
104
164
  return { ok: false };
105
165
  }
166
+ async done(taskName) {
167
+ return this.stop(taskName);
168
+ }
106
169
  async stopProfile(profileName) {
107
170
  const conn = this.connections.get(profileName);
108
171
  if (conn) {
@@ -124,27 +187,118 @@ export class BrowserService {
124
187
  }
125
188
  async navigate(taskId, url, profileName) {
126
189
  const { conn, task } = await this.findTask(taskId, profileName);
190
+ // If we have a current tab, navigate in it (reuse)
191
+ const currentShortId = task.currentTabId;
192
+ if (currentShortId && task.tabs[currentShortId]) {
193
+ const cdpTargetId = task.tabs[currentShortId];
194
+ const sessionId = await this.getSessionId(conn, cdpTargetId);
195
+ await conn.cdp.send('Page.navigate', { url }, sessionId);
196
+ await this.saveTaskState(task.profile, conn.tasks);
197
+ return { tabId: currentShortId, url, created: false };
198
+ }
199
+ // No current tab - create one
127
200
  if (conn.electron) {
128
- const tabId = task.windowTargetId || task.tabIds[0];
129
- if (!tabId) {
201
+ const cdpTargetId = conn.windowId;
202
+ if (!cdpTargetId) {
130
203
  throw new Error('No existing tab to navigate in Electron app');
131
204
  }
132
- const sessionId = await this.getSessionId(conn, tabId);
205
+ const shortId = generateShortId();
206
+ const sessionId = await this.getSessionId(conn, cdpTargetId);
133
207
  await conn.cdp.send('Page.navigate', { url }, sessionId);
134
- if (!task.tabIds.includes(tabId)) {
135
- task.tabIds.push(tabId);
136
- }
208
+ task.tabs[shortId] = cdpTargetId;
209
+ task.currentTabId = shortId;
137
210
  await this.saveTaskState(task.profile, conn.tasks);
138
- return { tabId, url };
211
+ return { tabId: shortId, url, created: true };
139
212
  }
213
+ // Chrome: create new tab
140
214
  const result = (await conn.cdp.send('Target.createTarget', {
141
215
  url,
142
216
  }));
143
- const tabId = result.targetId;
144
- task.tabIds.push(tabId);
217
+ const shortId = generateShortId();
218
+ task.tabs[shortId] = result.targetId;
219
+ task.currentTabId = shortId;
145
220
  this.invalidateTargetCache(conn);
146
221
  await this.saveTaskState(task.profile, conn.tasks);
147
- return { tabId, url };
222
+ return { tabId: shortId, url, created: true };
223
+ }
224
+ async tabAdd(taskId, url, profileName) {
225
+ const { conn, task } = await this.findTask(taskId, profileName);
226
+ if (conn.electron) {
227
+ throw new Error('Electron apps do not support opening additional tabs');
228
+ }
229
+ const result = (await conn.cdp.send('Target.createTarget', {
230
+ url,
231
+ }));
232
+ const shortId = generateShortId();
233
+ task.tabs[shortId] = result.targetId;
234
+ task.currentTabId = shortId; // new tab becomes current
235
+ this.invalidateTargetCache(conn);
236
+ await this.saveTaskState(task.profile, conn.tasks);
237
+ return { tabId: shortId, url };
238
+ }
239
+ async tabFocus(taskId, tabHint) {
240
+ const { conn, task } = await this.findTask(taskId);
241
+ const resolvedTabId = await this.resolveTabHint(conn, task, tabHint);
242
+ task.currentTabId = resolvedTabId;
243
+ await this.saveTaskState(task.profile, conn.tasks);
244
+ return { tabId: resolvedTabId };
245
+ }
246
+ async tabList(taskId) {
247
+ const { conn, task } = await this.findTask(taskId);
248
+ const targets = (await conn.cdp.send('Target.getTargets'));
249
+ const tabs = [];
250
+ for (const [shortId, cdpId] of Object.entries(task.tabs)) {
251
+ const target = targets.targetInfos.find((t) => t.targetId === cdpId);
252
+ if (target) {
253
+ tabs.push({
254
+ id: shortId,
255
+ url: target.url,
256
+ title: target.title,
257
+ current: shortId === task.currentTabId,
258
+ });
259
+ }
260
+ }
261
+ return tabs;
262
+ }
263
+ async resolveTabHint(conn, task, hint) {
264
+ // Exact match
265
+ if (task.tabs[hint])
266
+ return hint;
267
+ // Prefix match
268
+ const byPrefix = Object.keys(task.tabs).filter((id) => id.startsWith(hint));
269
+ if (byPrefix.length === 1)
270
+ return byPrefix[0];
271
+ if (byPrefix.length > 1) {
272
+ throw new Error(`Ambiguous tab hint "${hint}" — matches ${byPrefix.length} tabs`);
273
+ }
274
+ // URL substring match
275
+ const targets = (await conn.cdp.send('Target.getTargets'));
276
+ const matches = [];
277
+ for (const [shortId, cdpId] of Object.entries(task.tabs)) {
278
+ const target = targets.targetInfos.find((t) => t.targetId === cdpId);
279
+ if (target && target.url.includes(hint)) {
280
+ matches.push(shortId);
281
+ }
282
+ }
283
+ if (matches.length === 1)
284
+ return matches[0];
285
+ if (matches.length > 1) {
286
+ throw new Error(`Ambiguous tab hint "${hint}" — matches ${matches.length} tabs by URL`);
287
+ }
288
+ throw new Error(`Tab "${hint}" not found`);
289
+ }
290
+ resolveCurrentTab(task) {
291
+ const tabIds = Object.keys(task.tabs);
292
+ const id = task.currentTabId ?? tabIds[tabIds.length - 1];
293
+ if (!id)
294
+ throw new Error('No tabs open for this task');
295
+ return id;
296
+ }
297
+ getCdpTargetId(task, shortId) {
298
+ const cdpId = task.tabs[shortId];
299
+ if (!cdpId)
300
+ throw new Error(`Tab ${shortId} not found`);
301
+ return cdpId;
148
302
  }
149
303
  async tabs(taskId, profileName) {
150
304
  if (taskId) {
@@ -160,42 +314,53 @@ export class BrowserService {
160
314
  }
161
315
  return allTabs;
162
316
  }
163
- async close(taskId, tabId) {
317
+ async tabClose(taskId, tabHint) {
164
318
  const { conn, task } = await this.findTask(taskId);
165
- if (tabId !== undefined) {
166
- await conn.cdp.send('Target.closeTarget', { targetId: tabId });
167
- task.tabIds = task.tabIds.filter((id) => id !== tabId);
168
- conn.sessionCache.delete(tabId);
319
+ if (tabHint !== undefined) {
320
+ const shortId = await this.resolveTabHint(conn, task, tabHint);
321
+ const cdpId = task.tabs[shortId];
322
+ if (cdpId) {
323
+ await conn.cdp.send('Target.closeTarget', { targetId: cdpId });
324
+ conn.sessionCache.delete(cdpId);
325
+ delete task.tabs[shortId];
326
+ // Update currentTabId if we closed the current tab
327
+ if (task.currentTabId === shortId) {
328
+ const remaining = Object.keys(task.tabs);
329
+ task.currentTabId = remaining.length > 0 ? remaining[remaining.length - 1] : undefined;
330
+ }
331
+ }
169
332
  }
170
333
  else {
171
- await Promise.all(task.tabIds.map((id) => conn.cdp.send('Target.closeTarget', { targetId: id }).catch(() => { })));
172
- for (const id of task.tabIds) {
173
- conn.sessionCache.delete(id);
334
+ // Close all tabs
335
+ await Promise.all(Object.values(task.tabs).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => { })));
336
+ for (const cdpId of Object.values(task.tabs)) {
337
+ conn.sessionCache.delete(cdpId);
174
338
  }
175
- task.tabIds = [];
339
+ task.tabs = {};
340
+ task.currentTabId = undefined;
176
341
  }
177
342
  this.invalidateTargetCache(conn);
178
343
  await this.saveTaskState(task.profile, conn.tasks);
179
344
  }
180
- async evaluate(taskId, tabId, expression) {
181
- const { conn } = await this.findTask(taskId);
182
- const target = await this.getTarget(conn, tabId);
345
+ async evaluate(taskId, tabHint, expression) {
346
+ const { conn, task } = await this.findTask(taskId);
347
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
348
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
349
+ const target = await this.getTarget(conn, cdpTargetId);
183
350
  if (!target) {
184
- throw new Error(`Tab ${tabId} not found`);
351
+ throw new Error(`Tab ${shortId} not found`);
185
352
  }
186
353
  const sessionId = await this.getSessionId(conn, target.targetId);
187
354
  const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId));
188
355
  return result.result.value;
189
356
  }
190
- async screenshot(taskId, tabId, outputPath) {
357
+ async screenshot(taskId, tabHint, outputPath) {
191
358
  const { conn, task } = await this.findTask(taskId);
192
- const targetTabId = tabId ?? task.tabIds[task.tabIds.length - 1];
193
- if (targetTabId === undefined) {
194
- throw new Error('No tabs open for this task');
195
- }
196
- const target = await this.getTarget(conn, targetTabId);
359
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
360
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
361
+ const target = await this.getTarget(conn, cdpTargetId);
197
362
  if (!target) {
198
- throw new Error(`Tab ${targetTabId} not found`);
363
+ throw new Error(`Tab ${shortId} not found`);
199
364
  }
200
365
  const sessionId = await this.getSessionId(conn, target.targetId);
201
366
  const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 }, sessionId));
@@ -209,39 +374,42 @@ export class BrowserService {
209
374
  quality -= 10;
210
375
  }
211
376
  }
212
- const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', taskId);
377
+ const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
213
378
  const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}.jpg`);
214
379
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
215
380
  await fs.promises.writeFile(finalPath, buffer);
216
381
  return finalPath;
217
382
  }
218
383
  refsCache = new Map();
219
- async refs(taskId, tabId, opts = {}) {
384
+ async refs(taskId, tabHint, opts = {}) {
220
385
  const { conn, task } = await this.findTask(taskId);
221
- const targetTabId = tabId || task.tabIds[task.tabIds.length - 1];
222
- if (!targetTabId)
223
- throw new Error('No tabs open for this task');
224
- const target = await this.getTarget(conn, targetTabId);
386
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
387
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
388
+ const target = await this.getTarget(conn, cdpTargetId);
225
389
  if (!target)
226
- throw new Error(`Tab ${targetTabId} not found`);
390
+ throw new Error(`Tab ${shortId} not found`);
227
391
  const sessionId = await this.getSessionId(conn, target.targetId);
228
392
  return getRefs(conn.cdp, sessionId, opts);
229
393
  }
230
- async click(taskId, tabId, ref) {
231
- const { conn } = await this.findTask(taskId);
232
- const target = await this.getTarget(conn, tabId);
394
+ async click(taskId, ref, tabHint) {
395
+ const { conn, task } = await this.findTask(taskId);
396
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
397
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
398
+ const target = await this.getTarget(conn, cdpTargetId);
233
399
  if (!target)
234
- throw new Error(`Tab ${tabId} not found`);
400
+ throw new Error(`Tab ${shortId} not found`);
235
401
  const sessionId = await this.getSessionId(conn, target.targetId);
236
402
  const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
237
403
  const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
238
404
  await clickAtCoords(conn.cdp, sessionId, x, y);
239
405
  }
240
- async type(taskId, tabId, ref, text) {
241
- const { conn } = await this.findTask(taskId);
242
- const target = await this.getTarget(conn, tabId);
406
+ async type(taskId, ref, text, tabHint) {
407
+ const { conn, task } = await this.findTask(taskId);
408
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
409
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
410
+ const target = await this.getTarget(conn, cdpTargetId);
243
411
  if (!target)
244
- throw new Error(`Tab ${tabId} not found`);
412
+ throw new Error(`Tab ${shortId} not found`);
245
413
  const sessionId = await this.getSessionId(conn, target.targetId);
246
414
  const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
247
415
  const node = nodeMap.get(ref);
@@ -252,40 +420,394 @@ export class BrowserService {
252
420
  }
253
421
  await typeText(conn.cdp, sessionId, text);
254
422
  }
255
- async press(taskId, tabId, key) {
256
- const { conn } = await this.findTask(taskId);
257
- const target = await this.getTarget(conn, tabId);
423
+ async press(taskId, key, tabHint) {
424
+ const { conn, task } = await this.findTask(taskId);
425
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
426
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
427
+ const target = await this.getTarget(conn, cdpTargetId);
258
428
  if (!target)
259
- throw new Error(`Tab ${tabId} not found`);
429
+ throw new Error(`Tab ${shortId} not found`);
260
430
  const sessionId = await this.getSessionId(conn, target.targetId);
261
431
  await pressKey(conn.cdp, sessionId, key);
262
432
  }
263
- async hover(taskId, tabId, ref) {
264
- const { conn } = await this.findTask(taskId);
265
- const target = await this.getTarget(conn, tabId);
433
+ async hover(taskId, ref, tabHint) {
434
+ const { conn, task } = await this.findTask(taskId);
435
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
436
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
437
+ const target = await this.getTarget(conn, cdpTargetId);
266
438
  if (!target)
267
- throw new Error(`Tab ${tabId} not found`);
439
+ throw new Error(`Tab ${shortId} not found`);
268
440
  const sessionId = await this.getSessionId(conn, target.targetId);
269
441
  const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
270
442
  const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
271
443
  await hoverAtCoords(conn.cdp, sessionId, x, y);
272
444
  }
445
+ async scroll(taskId, deltaX, deltaY, atX, atY, tabHint) {
446
+ const { conn, task } = await this.findTask(taskId);
447
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
448
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
449
+ const target = await this.getTarget(conn, cdpTargetId);
450
+ if (!target)
451
+ throw new Error(`Tab ${shortId} not found`);
452
+ const sessionId = await this.getSessionId(conn, target.targetId);
453
+ await scrollAtCoords(conn.cdp, sessionId, atX ?? 0, atY ?? 0, deltaX, deltaY);
454
+ }
273
455
  async status(profileName) {
456
+ const seen = new Set();
274
457
  const statuses = [];
275
- if (profileName) {
276
- const status = await this.getProfileStatus(profileName);
277
- if (status)
458
+ const candidates = profileName ? [profileName] : Array.from(this.connections.keys());
459
+ for (const name of candidates) {
460
+ const status = await this.getProfileStatus(name);
461
+ if (status) {
278
462
  statuses.push(status);
463
+ seen.add(name);
464
+ }
279
465
  }
280
- else {
281
- for (const name of this.connections.keys()) {
282
- const status = await this.getProfileStatus(name);
283
- if (status)
284
- statuses.push(status);
466
+ if (!profileName) {
467
+ const profiles = await listProfiles();
468
+ for (const profile of profiles) {
469
+ if (seen.has(profile.name))
470
+ continue;
471
+ const reconciled = await this.reconcileFromDisk(profile.name);
472
+ if (reconciled)
473
+ statuses.push(reconciled);
285
474
  }
286
475
  }
476
+ else if (!seen.has(profileName)) {
477
+ const reconciled = await this.reconcileFromDisk(profileName);
478
+ if (reconciled)
479
+ statuses.push(reconciled);
480
+ }
287
481
  return statuses;
288
482
  }
483
+ async reconcileFromDisk(profileName) {
484
+ const info = getRunningChromeInfo(profileName);
485
+ if (!info)
486
+ return null;
487
+ const profile = await getProfile(profileName);
488
+ const tasks = this.loadTaskState(profileName);
489
+ const taskStatuses = [];
490
+ for (const [, task] of tasks) {
491
+ taskStatuses.push({
492
+ id: task.id,
493
+ name: task.name,
494
+ tabCount: Object.keys(task.tabs).length,
495
+ currentTabId: task.currentTabId,
496
+ createdAt: task.createdAt,
497
+ });
498
+ }
499
+ const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
500
+ return {
501
+ name: profileName,
502
+ running: true,
503
+ port: info.port,
504
+ pid: info.pid,
505
+ configuredPort: configuredPort !== info.port ? configuredPort : undefined,
506
+ tasks: taskStatuses,
507
+ };
508
+ }
509
+ // ─── Viewport & Device Emulation ──────────────────────────────────────────────
510
+ async setViewport(taskId, width, height, options = {}) {
511
+ const { conn, task } = await this.findTask(taskId);
512
+ const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
513
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
514
+ const target = await this.getTarget(conn, cdpTargetId);
515
+ if (!target)
516
+ throw new Error(`Tab ${shortId} not found`);
517
+ const sessionId = await this.getSessionId(conn, target.targetId);
518
+ await conn.cdp.send('Emulation.setDeviceMetricsOverride', {
519
+ width,
520
+ height,
521
+ deviceScaleFactor: options.deviceScaleFactor ?? 1,
522
+ mobile: options.mobile ?? false,
523
+ }, sessionId);
524
+ }
525
+ async setDevice(taskId, deviceName, tabHint) {
526
+ const { getDevice } = await import('./devices.js');
527
+ const device = getDevice(deviceName);
528
+ if (!device) {
529
+ const { listDevices } = await import('./devices.js');
530
+ throw new Error(`Unknown device "${deviceName}". Available: ${listDevices().join(', ')}`);
531
+ }
532
+ await this.setViewport(taskId, device.width, device.height, {
533
+ mobile: device.mobile,
534
+ deviceScaleFactor: device.deviceScaleFactor,
535
+ tabHint,
536
+ });
537
+ }
538
+ // ─── Console & Errors ────────────────────────────────────────────────────────
539
+ async enableRuntimeForSession(conn, sessionId) {
540
+ const key = `${sessionId}:Runtime`;
541
+ if (this.enabledSessions.get(sessionId)?.has('Runtime'))
542
+ return;
543
+ await conn.cdp.send('Runtime.enable', {}, sessionId);
544
+ if (!this.enabledSessions.has(sessionId)) {
545
+ this.enabledSessions.set(sessionId, new Set());
546
+ }
547
+ this.enabledSessions.get(sessionId).add('Runtime');
548
+ conn.cdp.on('Runtime.consoleAPICalled', (params) => {
549
+ if (params.sessionId !== sessionId)
550
+ return;
551
+ const taskId = this.findTaskBySession(conn, sessionId);
552
+ if (!taskId)
553
+ return;
554
+ const entry = {
555
+ level: params.type === 'warning' ? 'warn' : params.type,
556
+ text: params.args?.map((a) => a.value ?? a.description ?? '').join(' ') || '',
557
+ timestamp: Date.now(),
558
+ url: params.stackTrace?.callFrames?.[0]?.url,
559
+ line: params.stackTrace?.callFrames?.[0]?.lineNumber,
560
+ };
561
+ if (!this.consoleLogs.has(taskId))
562
+ this.consoleLogs.set(taskId, []);
563
+ const logs = this.consoleLogs.get(taskId);
564
+ logs.push(entry);
565
+ if (logs.length > 1000)
566
+ logs.shift();
567
+ });
568
+ conn.cdp.on('Runtime.exceptionThrown', (params) => {
569
+ if (params.sessionId !== sessionId)
570
+ return;
571
+ const taskId = this.findTaskBySession(conn, sessionId);
572
+ if (!taskId)
573
+ return;
574
+ const ex = params.exceptionDetails;
575
+ const entry = {
576
+ message: ex.exception?.description || ex.text || 'Unknown error',
577
+ stack: ex.stackTrace?.callFrames?.map((f) => ` at ${f.functionName || '<anonymous>'} (${f.url}:${f.lineNumber})`).join('\n'),
578
+ timestamp: Date.now(),
579
+ url: ex.url,
580
+ line: ex.lineNumber,
581
+ };
582
+ if (!this.pageErrors.has(taskId))
583
+ this.pageErrors.set(taskId, []);
584
+ const errors = this.pageErrors.get(taskId);
585
+ errors.push(entry);
586
+ if (errors.length > 500)
587
+ errors.shift();
588
+ });
589
+ }
590
+ async getConsoleLogs(taskId, options = {}) {
591
+ const { conn, task } = await this.findTask(taskId);
592
+ const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
593
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
594
+ const target = await this.getTarget(conn, cdpTargetId);
595
+ if (!target)
596
+ throw new Error(`Tab ${shortId} not found`);
597
+ const sessionId = await this.getSessionId(conn, target.targetId);
598
+ await this.enableRuntimeForSession(conn, sessionId);
599
+ let logs = this.consoleLogs.get(taskId) || [];
600
+ if (options.level) {
601
+ logs = logs.filter((l) => l.level === options.level);
602
+ }
603
+ if (options.clear) {
604
+ this.consoleLogs.set(taskId, []);
605
+ }
606
+ return logs;
607
+ }
608
+ async getErrors(taskId, options = {}) {
609
+ const { conn, task } = await this.findTask(taskId);
610
+ const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
611
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
612
+ const target = await this.getTarget(conn, cdpTargetId);
613
+ if (!target)
614
+ throw new Error(`Tab ${shortId} not found`);
615
+ const sessionId = await this.getSessionId(conn, target.targetId);
616
+ await this.enableRuntimeForSession(conn, sessionId);
617
+ const errors = this.pageErrors.get(taskId) || [];
618
+ if (options.clear) {
619
+ this.pageErrors.set(taskId, []);
620
+ }
621
+ return errors;
622
+ }
623
+ // ─── Network Requests ────────────────────────────────────────────────────────
624
+ async enableNetworkForSession(conn, sessionId, taskId) {
625
+ if (this.enabledSessions.get(sessionId)?.has('Network'))
626
+ return;
627
+ await conn.cdp.send('Network.enable', {}, sessionId);
628
+ if (!this.enabledSessions.has(sessionId)) {
629
+ this.enabledSessions.set(sessionId, new Set());
630
+ }
631
+ this.enabledSessions.get(sessionId).add('Network');
632
+ const requestMap = new Map();
633
+ conn.cdp.on('Network.requestWillBeSent', (params) => {
634
+ if (params.sessionId !== sessionId)
635
+ return;
636
+ const req = {
637
+ id: params.requestId,
638
+ url: params.request.url,
639
+ method: params.request.method,
640
+ timestamp: Date.now(),
641
+ };
642
+ requestMap.set(params.requestId, req);
643
+ if (!this.networkRequests.has(taskId))
644
+ this.networkRequests.set(taskId, []);
645
+ const reqs = this.networkRequests.get(taskId);
646
+ reqs.push(req);
647
+ if (reqs.length > 500)
648
+ reqs.shift();
649
+ });
650
+ conn.cdp.on('Network.responseReceived', (params) => {
651
+ if (params.sessionId !== sessionId)
652
+ return;
653
+ const req = requestMap.get(params.requestId);
654
+ if (req) {
655
+ req.status = params.response.status;
656
+ req.mimeType = params.response.mimeType;
657
+ }
658
+ });
659
+ }
660
+ async getNetworkRequests(taskId, options = {}) {
661
+ const { conn, task } = await this.findTask(taskId);
662
+ const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
663
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
664
+ const target = await this.getTarget(conn, cdpTargetId);
665
+ if (!target)
666
+ throw new Error(`Tab ${shortId} not found`);
667
+ const sessionId = await this.getSessionId(conn, target.targetId);
668
+ await this.enableNetworkForSession(conn, sessionId, taskId);
669
+ let requests = this.networkRequests.get(taskId) || [];
670
+ if (options.filter) {
671
+ const f = options.filter.toLowerCase();
672
+ requests = requests.filter((r) => r.url.toLowerCase().includes(f));
673
+ }
674
+ if (options.clear) {
675
+ this.networkRequests.set(taskId, []);
676
+ }
677
+ return requests;
678
+ }
679
+ async getResponseBody(taskId, urlPattern, options = {}) {
680
+ const { conn, task } = await this.findTask(taskId);
681
+ const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
682
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
683
+ const target = await this.getTarget(conn, cdpTargetId);
684
+ if (!target)
685
+ throw new Error(`Tab ${shortId} not found`);
686
+ const sessionId = await this.getSessionId(conn, target.targetId);
687
+ await this.enableNetworkForSession(conn, sessionId, taskId);
688
+ const timeout = options.timeout ?? 30000;
689
+ const maxChars = options.maxChars ?? 200000;
690
+ const start = Date.now();
691
+ const pattern = urlPattern.includes('*')
692
+ ? new RegExp(urlPattern.replace(/\*/g, '.*'), 'i')
693
+ : null;
694
+ while (Date.now() - start < timeout) {
695
+ const requests = this.networkRequests.get(taskId) || [];
696
+ const match = requests.find((r) => pattern ? pattern.test(r.url) : r.url.includes(urlPattern));
697
+ if (match && match.status) {
698
+ try {
699
+ const { body, base64Encoded } = (await conn.cdp.send('Network.getResponseBody', { requestId: match.id }, sessionId));
700
+ const text = base64Encoded ? Buffer.from(body, 'base64').toString('utf-8') : body;
701
+ return text.slice(0, maxChars);
702
+ }
703
+ catch {
704
+ // Request may have been evicted, continue waiting
705
+ }
706
+ }
707
+ await new Promise((r) => setTimeout(r, 200));
708
+ }
709
+ throw new Error(`No response matching "${urlPattern}" within ${timeout}ms`);
710
+ }
711
+ // ─── Wait Conditions ─────────────────────────────────────────────────────────
712
+ async wait(taskId, type, value, options = {}) {
713
+ const timeout = options.timeout ?? 30000;
714
+ if (type === 'time') {
715
+ await new Promise((r) => setTimeout(r, typeof value === 'number' ? value : parseInt(value, 10)));
716
+ return;
717
+ }
718
+ const { conn, task } = await this.findTask(taskId);
719
+ const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
720
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
721
+ const target = await this.getTarget(conn, cdpTargetId);
722
+ if (!target)
723
+ throw new Error(`Tab ${shortId} not found`);
724
+ const sessionId = await this.getSessionId(conn, target.targetId);
725
+ const start = Date.now();
726
+ while (Date.now() - start < timeout) {
727
+ let condition = false;
728
+ if (type === 'selector') {
729
+ const result = (await conn.cdp.send('Runtime.evaluate', { expression: `!!document.querySelector(${JSON.stringify(value)})`, returnByValue: true }, sessionId));
730
+ condition = result.result.value === true;
731
+ }
732
+ else if (type === 'url') {
733
+ const result = (await conn.cdp.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, sessionId));
734
+ const pattern = value.includes('*')
735
+ ? new RegExp(value.replace(/\*/g, '.*'), 'i')
736
+ : null;
737
+ condition = pattern ? pattern.test(result.result.value) : result.result.value.includes(value);
738
+ }
739
+ else if (type === 'function') {
740
+ const result = (await conn.cdp.send('Runtime.evaluate', { expression: `!!(${value})`, returnByValue: true }, sessionId));
741
+ condition = result.result.value === true;
742
+ }
743
+ else if (type === 'load') {
744
+ const result = (await conn.cdp.send('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true }, sessionId));
745
+ if (value === 'domcontentloaded') {
746
+ condition = result.result.value !== 'loading';
747
+ }
748
+ else if (value === 'load' || value === 'complete') {
749
+ condition = result.result.value === 'complete';
750
+ }
751
+ else if (value === 'networkidle') {
752
+ // Simplified: check if document is complete
753
+ condition = result.result.value === 'complete';
754
+ }
755
+ }
756
+ if (condition)
757
+ return;
758
+ await new Promise((r) => setTimeout(r, 100));
759
+ }
760
+ throw new Error(`Wait condition "${type}:${value}" not met within ${timeout}ms`);
761
+ }
762
+ // ─── Downloads ───────────────────────────────────────────────────────────────
763
+ async setDownloadPath(taskId, downloadPath, tabHint) {
764
+ const { conn, task } = await this.findTask(taskId);
765
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
766
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
767
+ const target = await this.getTarget(conn, cdpTargetId);
768
+ if (!target)
769
+ throw new Error(`Tab ${shortId} not found`);
770
+ const sessionId = await this.getSessionId(conn, target.targetId);
771
+ await conn.cdp.send('Browser.setDownloadBehavior', {
772
+ behavior: 'allow',
773
+ downloadPath,
774
+ eventsEnabled: true,
775
+ }, sessionId);
776
+ this.pendingDownloads.set(taskId, { path: downloadPath, completed: false });
777
+ conn.cdp.on('Browser.downloadProgress', (params) => {
778
+ if (params.state === 'completed') {
779
+ const dl = this.pendingDownloads.get(taskId);
780
+ if (dl) {
781
+ dl.completed = true;
782
+ dl.filename = params.suggestedFilename;
783
+ }
784
+ }
785
+ });
786
+ }
787
+ async waitForDownload(taskId, timeout = 60000) {
788
+ const start = Date.now();
789
+ while (Date.now() - start < timeout) {
790
+ const dl = this.pendingDownloads.get(taskId);
791
+ if (dl?.completed) {
792
+ const fullPath = dl.filename ? `${dl.path}/${dl.filename}` : dl.path;
793
+ this.pendingDownloads.delete(taskId);
794
+ return fullPath;
795
+ }
796
+ await new Promise((r) => setTimeout(r, 200));
797
+ }
798
+ throw new Error(`Download not completed within ${timeout}ms`);
799
+ }
800
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
801
+ findTaskBySession(conn, sessionId) {
802
+ for (const [taskId, task] of conn.tasks) {
803
+ for (const tabId of Object.values(task.tabs)) {
804
+ if (conn.sessionCache.get(tabId) === sessionId) {
805
+ return taskId;
806
+ }
807
+ }
808
+ }
809
+ return undefined;
810
+ }
289
811
  async shutdown() {
290
812
  for (const [, conn] of this.connections) {
291
813
  conn.cdp.close();
@@ -307,7 +829,8 @@ export class BrowserService {
307
829
  }
308
830
  const forkName = `${profile.name}.${forkNum}`;
309
831
  const port = allocatePort();
310
- const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, profile.chrome, profile.secrets, profile.binary);
832
+ const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
833
+ const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary);
311
834
  const cdp = new CDPClient();
312
835
  await cdp.connect(wsUrl);
313
836
  await this.enableDomains(cdp);
@@ -326,7 +849,8 @@ export class BrowserService {
326
849
  async connectProfile(profile) {
327
850
  const existingInfo = getRunningChromeInfo(profile.name);
328
851
  if (existingInfo) {
329
- const wsUrl = await discoverBrowserWsUrl(existingInfo.port);
852
+ const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
853
+ verifyBrowserIdentity(browser, profile.browser, existingInfo.port);
330
854
  const cdp = new CDPClient();
331
855
  await cdp.connect(wsUrl);
332
856
  await this.enableDomains(cdp);
@@ -346,7 +870,10 @@ export class BrowserService {
346
870
  if (conn)
347
871
  return conn;
348
872
  }
349
- catch {
873
+ catch (err) {
874
+ if (err instanceof Error && err.message.startsWith('Browser identity mismatch')) {
875
+ throw err;
876
+ }
350
877
  // Try next endpoint
351
878
  }
352
879
  }
@@ -393,7 +920,8 @@ export class BrowserService {
393
920
  }
394
921
  if (url.protocol === 'http:' || url.protocol === 'https:') {
395
922
  const port = parseInt(url.port || (url.protocol === 'https:' ? '443' : '80'), 10);
396
- const wsUrl = await discoverBrowserWsUrl(port, url.hostname);
923
+ const { wsUrl, browser } = await discoverBrowserWsUrl(port, url.hostname);
924
+ verifyBrowserIdentity(browser, profile.browser, port, url.hostname);
397
925
  const cdp = new CDPClient();
398
926
  await cdp.connect(wsUrl);
399
927
  await this.enableDomains(cdp);
@@ -411,30 +939,30 @@ export class BrowserService {
411
939
  async enableDomains(cdp) {
412
940
  await cdp.send('Target.setDiscoverTargets', { discover: true });
413
941
  }
414
- async createTaskWindow(conn, _taskId) {
415
- if (conn.electron) {
942
+ async getOrCreateWindow(conn) {
943
+ // Already have a window for this profile?
944
+ if (conn.windowId) {
945
+ // Verify it still exists via CDP
416
946
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
417
- const pageTarget = targetInfos.find((t) => t.type === 'page');
418
- if (pageTarget) {
419
- return { windowTargetId: pageTarget.targetId };
420
- }
421
- // No existing page - try to create one (works on some Electron apps)
422
- try {
423
- const result = (await conn.cdp.send('Target.createTarget', {
424
- url: 'about:blank',
425
- newWindow: true,
426
- }));
427
- return { windowTargetId: result.targetId };
428
- }
429
- catch {
430
- throw new Error('No page targets found and unable to create new window');
947
+ if (targetInfos.some((t) => t.targetId === conn.windowId && t.type === 'page')) {
948
+ return conn.windowId;
431
949
  }
950
+ // Window was closed, fall through to find/create new
951
+ }
952
+ // Check if browser already has a page target we can use
953
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
954
+ const existing = targetInfos.find((t) => t.type === 'page');
955
+ if (existing) {
956
+ conn.windowId = existing.targetId;
957
+ return existing.targetId;
432
958
  }
959
+ // First ever use - create window
433
960
  const result = (await conn.cdp.send('Target.createTarget', {
434
961
  url: 'about:blank',
435
962
  newWindow: true,
436
963
  }));
437
- return { windowTargetId: result.targetId };
964
+ conn.windowId = result.targetId;
965
+ return result.targetId;
438
966
  }
439
967
  async findTask(taskId, profileName) {
440
968
  if (profileName) {
@@ -458,37 +986,74 @@ export class BrowserService {
458
986
  }
459
987
  async getTabsForTask(cdp, task) {
460
988
  const targets = (await cdp.send('Target.getTargets'));
461
- return task.tabIds
462
- .map((id) => {
463
- const target = targets.targetInfos.find((t) => t.targetId === id);
464
- if (!target)
465
- return null;
466
- return {
467
- id,
468
- url: target.url,
469
- title: target.title,
470
- task: task.id,
471
- };
472
- })
473
- .filter((t) => t !== null);
989
+ const tabs = [];
990
+ for (const [shortId, cdpId] of Object.entries(task.tabs)) {
991
+ const target = targets.targetInfos.find((t) => t.targetId === cdpId);
992
+ if (target) {
993
+ tabs.push({
994
+ id: shortId,
995
+ url: target.url,
996
+ title: target.title,
997
+ task: task.name,
998
+ });
999
+ }
1000
+ }
1001
+ return tabs;
474
1002
  }
475
1003
  async getProfileStatus(profileName) {
476
1004
  const conn = this.connections.get(profileName);
477
1005
  if (!conn)
478
1006
  return null;
1007
+ // Fetch all targets once for efficiency
1008
+ let targets = [];
1009
+ try {
1010
+ const result = (await conn.cdp.send('Target.getTargets'));
1011
+ targets = result.targetInfos;
1012
+ }
1013
+ catch {
1014
+ // CDP not responding, fall back to metadata only
1015
+ }
479
1016
  const tasks = [];
480
1017
  for (const [, task] of conn.tasks) {
1018
+ const tabs = [];
1019
+ const domainSet = new Set();
1020
+ for (const [shortId, cdpId] of Object.entries(task.tabs)) {
1021
+ const target = targets.find((t) => t.targetId === cdpId);
1022
+ if (target) {
1023
+ tabs.push({
1024
+ id: shortId,
1025
+ url: target.url,
1026
+ title: target.title,
1027
+ current: shortId === task.currentTabId,
1028
+ });
1029
+ try {
1030
+ const domain = new URL(target.url).hostname.replace(/^www\./, '');
1031
+ if (domain && domain !== 'blank')
1032
+ domainSet.add(domain);
1033
+ }
1034
+ catch {
1035
+ // invalid URL
1036
+ }
1037
+ }
1038
+ }
481
1039
  tasks.push({
482
1040
  id: task.id,
483
- tabCount: task.tabIds.length,
1041
+ name: task.name,
1042
+ tabCount: Object.keys(task.tabs).length,
1043
+ currentTabId: task.currentTabId,
484
1044
  createdAt: task.createdAt,
1045
+ tabs: tabs.length > 0 ? tabs : undefined,
1046
+ domains: domainSet.size > 0 ? Array.from(domainSet) : undefined,
485
1047
  });
486
1048
  }
1049
+ const profile = await getProfile(profileName);
1050
+ const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
487
1051
  return {
488
1052
  name: profileName,
489
1053
  running: true,
490
1054
  port: conn.port,
491
1055
  pid: conn.pid,
1056
+ configuredPort: configuredPort !== conn.port ? configuredPort : undefined,
492
1057
  tasks,
493
1058
  };
494
1059
  }
@@ -528,6 +1093,76 @@ export class BrowserService {
528
1093
  return new Map();
529
1094
  }
530
1095
  const state = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
531
- return new Map(Object.entries(state));
1096
+ const tasks = new Map();
1097
+ let needsMigration = false;
1098
+ for (const [key, raw] of Object.entries(state)) {
1099
+ const task = raw;
1100
+ // Migrate old format (tabIds array) to new format (tabs object)
1101
+ if (Array.isArray(task.tabIds) && !task.tabs) {
1102
+ needsMigration = true;
1103
+ const tabs = {};
1104
+ for (const cdpId of task.tabIds) {
1105
+ const shortId = generateShortId();
1106
+ tabs[shortId] = cdpId;
1107
+ }
1108
+ const tabIds = Object.keys(tabs);
1109
+ tasks.set(key, {
1110
+ id: task.id,
1111
+ name: task.name || key,
1112
+ profile: task.profile,
1113
+ tabs,
1114
+ currentTabId: tabIds.length > 0 ? tabIds[tabIds.length - 1] : undefined,
1115
+ createdAt: task.createdAt,
1116
+ pid: task.pid,
1117
+ });
1118
+ }
1119
+ else {
1120
+ tasks.set(key, task);
1121
+ }
1122
+ }
1123
+ // Save migrated data back to disk
1124
+ if (needsMigration) {
1125
+ const migratedState = Object.fromEntries(tasks);
1126
+ fs.writeFileSync(tasksFile, JSON.stringify(migratedState, null, 2));
1127
+ }
1128
+ return tasks;
1129
+ }
1130
+ async saveToHistory(task, domains) {
1131
+ const historyDir = getBrowserRuntimeDir();
1132
+ await fs.promises.mkdir(historyDir, { recursive: true });
1133
+ const historyFile = path.join(historyDir, 'history.json');
1134
+ let history = [];
1135
+ if (fs.existsSync(historyFile)) {
1136
+ try {
1137
+ history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
1138
+ }
1139
+ catch {
1140
+ // Corrupted file, start fresh
1141
+ }
1142
+ }
1143
+ history.unshift({
1144
+ id: task.id,
1145
+ name: task.name,
1146
+ profile: task.profile,
1147
+ createdAt: task.createdAt,
1148
+ endedAt: Date.now(),
1149
+ domains,
1150
+ tabCount: Object.keys(task.tabs).length,
1151
+ });
1152
+ // Keep only last 50 entries
1153
+ history = history.slice(0, 50);
1154
+ await fs.promises.writeFile(historyFile, JSON.stringify(history, null, 2));
1155
+ }
1156
+ async getHistory(limit = 10) {
1157
+ const historyFile = path.join(getBrowserRuntimeDir(), 'history.json');
1158
+ if (!fs.existsSync(historyFile))
1159
+ return [];
1160
+ try {
1161
+ const history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
1162
+ return history.slice(0, limit);
1163
+ }
1164
+ catch {
1165
+ return [];
1166
+ }
532
1167
  }
533
1168
  }