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