@phnx-labs/agents-cli 1.15.0 → 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 (87) hide show
  1. package/CHANGELOG.md +78 -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 +546 -75
  7. package/dist/commands/commands.js +72 -22
  8. package/dist/commands/daemon.js +2 -2
  9. package/dist/commands/fork.js +2 -2
  10. package/dist/commands/hooks.js +71 -26
  11. package/dist/commands/mcp.js +81 -39
  12. package/dist/commands/plugins.js +48 -15
  13. package/dist/commands/prune.js +23 -1
  14. package/dist/commands/pull.js +3 -3
  15. package/dist/commands/repo.js +1 -1
  16. package/dist/commands/routines.js +2 -2
  17. package/dist/commands/secrets.js +37 -1
  18. package/dist/commands/sessions.js +62 -19
  19. package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
  20. package/dist/commands/{init.js → setup.js} +22 -21
  21. package/dist/commands/skills.js +60 -19
  22. package/dist/commands/subagents.js +41 -13
  23. package/dist/commands/utils.d.ts +16 -0
  24. package/dist/commands/utils.js +32 -0
  25. package/dist/commands/view.js +61 -16
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +17 -20
  28. package/dist/lib/agents.js +2 -2
  29. package/dist/lib/auto-pull-worker.js +2 -3
  30. package/dist/lib/auto-pull.js +2 -2
  31. package/dist/lib/browser/cdp.d.ts +7 -1
  32. package/dist/lib/browser/cdp.js +29 -1
  33. package/dist/lib/browser/chrome.js +5 -2
  34. package/dist/lib/browser/devices.d.ts +4 -0
  35. package/dist/lib/browser/devices.js +27 -0
  36. package/dist/lib/browser/drivers/local.js +9 -4
  37. package/dist/lib/browser/drivers/ssh.js +9 -2
  38. package/dist/lib/browser/ipc.js +144 -23
  39. package/dist/lib/browser/profiles.d.ts +5 -2
  40. package/dist/lib/browser/profiles.js +77 -37
  41. package/dist/lib/browser/service.d.ts +81 -13
  42. package/dist/lib/browser/service.js +738 -131
  43. package/dist/lib/browser/types.d.ts +81 -3
  44. package/dist/lib/browser/types.js +16 -0
  45. package/dist/lib/cloud/rush.js +2 -2
  46. package/dist/lib/cloud/store.js +2 -2
  47. package/dist/lib/commands.d.ts +1 -0
  48. package/dist/lib/commands.js +6 -2
  49. package/dist/lib/daemon.js +2 -3
  50. package/dist/lib/doctor-diff.js +4 -4
  51. package/dist/lib/events.js +2 -2
  52. package/dist/lib/hooks.d.ts +11 -7
  53. package/dist/lib/hooks.js +125 -49
  54. package/dist/lib/migrate.d.ts +1 -1
  55. package/dist/lib/migrate.js +1178 -21
  56. package/dist/lib/models.js +2 -2
  57. package/dist/lib/permissions.d.ts +8 -8
  58. package/dist/lib/permissions.js +8 -8
  59. package/dist/lib/plugins.d.ts +30 -1
  60. package/dist/lib/plugins.js +75 -3
  61. package/dist/lib/pty-server.js +9 -10
  62. package/dist/lib/resources/hooks.d.ts +5 -1
  63. package/dist/lib/resources/hooks.js +21 -4
  64. package/dist/lib/rotate.js +3 -4
  65. package/dist/lib/session/active.d.ts +3 -0
  66. package/dist/lib/session/active.js +92 -6
  67. package/dist/lib/session/cloud.js +2 -2
  68. package/dist/lib/session/db.js +8 -3
  69. package/dist/lib/session/discover.js +30 -15
  70. package/dist/lib/session/team-filter.js +2 -2
  71. package/dist/lib/shims.d.ts +2 -2
  72. package/dist/lib/shims.js +6 -6
  73. package/dist/lib/skills.js +6 -2
  74. package/dist/lib/state.d.ts +86 -14
  75. package/dist/lib/state.js +150 -23
  76. package/dist/lib/subagents.d.ts +28 -0
  77. package/dist/lib/subagents.js +98 -1
  78. package/dist/lib/sync-manifest.d.ts +1 -1
  79. package/dist/lib/sync-manifest.js +3 -3
  80. package/dist/lib/teams/persistence.js +15 -5
  81. package/dist/lib/teams/registry.js +2 -2
  82. package/dist/lib/types.d.ts +32 -3
  83. package/dist/lib/types.js +3 -3
  84. package/dist/lib/usage.js +2 -2
  85. package/dist/lib/versions.js +20 -21
  86. package/package.json +1 -1
  87. package/scripts/postinstall.js +1 -1
@@ -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
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
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,84 @@ 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 };
73
99
  }
74
- async stop(taskId) {
100
+ async stop(taskName) {
75
101
  for (const [profileName, conn] of this.connections) {
76
- const task = conn.tasks.get(taskId);
102
+ const task = conn.tasks.get(taskName);
77
103
  if (task) {
78
- 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(() => {
79
129
  // Tab already closed
80
130
  })));
81
- for (const tabId of task.tabIds) {
82
- conn.sessionCache.delete(tabId);
131
+ for (const cdpId of Object.values(task.tabs)) {
132
+ conn.sessionCache.delete(cdpId);
83
133
  }
84
134
  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);
135
+ conn.tasks.delete(taskName);
94
136
  await this.saveTaskState(profileName, conn.tasks);
95
- emit('browser.close', { profile: profileName, task: taskId });
137
+ emit('browser.close', { profile: profileName, task: taskName });
96
138
  if (conn.forkedFrom && conn.tasks.size === 0) {
97
139
  conn.cdp.close();
98
140
  killChrome(conn.pid);
@@ -103,6 +145,9 @@ export class BrowserService {
103
145
  }
104
146
  return { ok: false };
105
147
  }
148
+ async done(taskName) {
149
+ return this.stop(taskName);
150
+ }
106
151
  async stopProfile(profileName) {
107
152
  const conn = this.connections.get(profileName);
108
153
  if (conn) {
@@ -124,27 +169,118 @@ export class BrowserService {
124
169
  }
125
170
  async navigate(taskId, url, profileName) {
126
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
127
182
  if (conn.electron) {
128
- const tabId = task.windowTargetId || task.tabIds[0];
129
- if (!tabId) {
183
+ const cdpTargetId = conn.windowId;
184
+ if (!cdpTargetId) {
130
185
  throw new Error('No existing tab to navigate in Electron app');
131
186
  }
132
- const sessionId = await this.getSessionId(conn, tabId);
187
+ const shortId = generateShortId();
188
+ const sessionId = await this.getSessionId(conn, cdpTargetId);
133
189
  await conn.cdp.send('Page.navigate', { url }, sessionId);
134
- if (!task.tabIds.includes(tabId)) {
135
- task.tabIds.push(tabId);
136
- }
190
+ task.tabs[shortId] = cdpTargetId;
191
+ task.currentTabId = shortId;
137
192
  await this.saveTaskState(task.profile, conn.tasks);
138
- return { tabId, url };
193
+ return { tabId: shortId, url, created: true };
139
194
  }
195
+ // Chrome: create new tab
140
196
  const result = (await conn.cdp.send('Target.createTarget', {
141
197
  url,
142
198
  }));
143
- const tabId = result.targetId;
144
- task.tabIds.push(tabId);
199
+ const shortId = generateShortId();
200
+ task.tabs[shortId] = result.targetId;
201
+ task.currentTabId = shortId;
145
202
  this.invalidateTargetCache(conn);
146
203
  await this.saveTaskState(task.profile, conn.tasks);
147
- 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;
148
284
  }
149
285
  async tabs(taskId, profileName) {
150
286
  if (taskId) {
@@ -160,42 +296,53 @@ export class BrowserService {
160
296
  }
161
297
  return allTabs;
162
298
  }
163
- async close(taskId, tabId) {
299
+ async tabClose(taskId, tabHint) {
164
300
  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);
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
+ }
169
314
  }
170
315
  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);
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);
174
320
  }
175
- task.tabIds = [];
321
+ task.tabs = {};
322
+ task.currentTabId = undefined;
176
323
  }
177
324
  this.invalidateTargetCache(conn);
178
325
  await this.saveTaskState(task.profile, conn.tasks);
179
326
  }
180
- async evaluate(taskId, tabId, expression) {
181
- const { conn } = await this.findTask(taskId);
182
- 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);
183
332
  if (!target) {
184
- throw new Error(`Tab ${tabId} not found`);
333
+ throw new Error(`Tab ${shortId} not found`);
185
334
  }
186
335
  const sessionId = await this.getSessionId(conn, target.targetId);
187
336
  const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId));
188
337
  return result.result.value;
189
338
  }
190
- async screenshot(taskId, tabId, outputPath) {
339
+ async screenshot(taskId, tabHint, outputPath) {
191
340
  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);
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);
197
344
  if (!target) {
198
- throw new Error(`Tab ${targetTabId} not found`);
345
+ throw new Error(`Tab ${shortId} not found`);
199
346
  }
200
347
  const sessionId = await this.getSessionId(conn, target.targetId);
201
348
  const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 }, sessionId));
@@ -209,39 +356,42 @@ export class BrowserService {
209
356
  quality -= 10;
210
357
  }
211
358
  }
212
- const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', taskId);
359
+ const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
213
360
  const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}.jpg`);
214
361
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
215
362
  await fs.promises.writeFile(finalPath, buffer);
216
363
  return finalPath;
217
364
  }
218
365
  refsCache = new Map();
219
- async refs(taskId, tabId, opts = {}) {
366
+ async refs(taskId, tabHint, opts = {}) {
220
367
  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);
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);
225
371
  if (!target)
226
- throw new Error(`Tab ${targetTabId} not found`);
372
+ throw new Error(`Tab ${shortId} not found`);
227
373
  const sessionId = await this.getSessionId(conn, target.targetId);
228
374
  return getRefs(conn.cdp, sessionId, opts);
229
375
  }
230
- async click(taskId, tabId, ref) {
231
- const { conn } = await this.findTask(taskId);
232
- 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);
233
381
  if (!target)
234
- throw new Error(`Tab ${tabId} not found`);
382
+ throw new Error(`Tab ${shortId} not found`);
235
383
  const sessionId = await this.getSessionId(conn, target.targetId);
236
384
  const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
237
385
  const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
238
386
  await clickAtCoords(conn.cdp, sessionId, x, y);
239
387
  }
240
- async type(taskId, tabId, ref, text) {
241
- const { conn } = await this.findTask(taskId);
242
- 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);
243
393
  if (!target)
244
- throw new Error(`Tab ${tabId} not found`);
394
+ throw new Error(`Tab ${shortId} not found`);
245
395
  const sessionId = await this.getSessionId(conn, target.targetId);
246
396
  const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
247
397
  const node = nodeMap.get(ref);
@@ -252,40 +402,384 @@ export class BrowserService {
252
402
  }
253
403
  await typeText(conn.cdp, sessionId, text);
254
404
  }
255
- async press(taskId, tabId, key) {
256
- const { conn } = await this.findTask(taskId);
257
- 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);
258
410
  if (!target)
259
- throw new Error(`Tab ${tabId} not found`);
411
+ throw new Error(`Tab ${shortId} not found`);
260
412
  const sessionId = await this.getSessionId(conn, target.targetId);
261
413
  await pressKey(conn.cdp, sessionId, key);
262
414
  }
263
- async hover(taskId, tabId, ref) {
264
- const { conn } = await this.findTask(taskId);
265
- 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);
266
420
  if (!target)
267
- throw new Error(`Tab ${tabId} not found`);
421
+ throw new Error(`Tab ${shortId} not found`);
268
422
  const sessionId = await this.getSessionId(conn, target.targetId);
269
423
  const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
270
424
  const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
271
425
  await hoverAtCoords(conn.cdp, sessionId, x, y);
272
426
  }
273
427
  async status(profileName) {
428
+ const seen = new Set();
274
429
  const statuses = [];
275
- if (profileName) {
276
- const status = await this.getProfileStatus(profileName);
277
- 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) {
278
434
  statuses.push(status);
435
+ seen.add(name);
436
+ }
279
437
  }
280
- else {
281
- for (const name of this.connections.keys()) {
282
- const status = await this.getProfileStatus(name);
283
- if (status)
284
- 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);
285
446
  }
286
447
  }
448
+ else if (!seen.has(profileName)) {
449
+ const reconciled = await this.reconcileFromDisk(profileName);
450
+ if (reconciled)
451
+ statuses.push(reconciled);
452
+ }
287
453
  return statuses;
288
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
+ }
289
783
  async shutdown() {
290
784
  for (const [, conn] of this.connections) {
291
785
  conn.cdp.close();
@@ -307,7 +801,8 @@ export class BrowserService {
307
801
  }
308
802
  const forkName = `${profile.name}.${forkNum}`;
309
803
  const port = allocatePort();
310
- const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, profile.chrome, profile.secrets, profile.binary);
804
+ const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
805
+ const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary);
311
806
  const cdp = new CDPClient();
312
807
  await cdp.connect(wsUrl);
313
808
  await this.enableDomains(cdp);
@@ -326,7 +821,8 @@ export class BrowserService {
326
821
  async connectProfile(profile) {
327
822
  const existingInfo = getRunningChromeInfo(profile.name);
328
823
  if (existingInfo) {
329
- const wsUrl = await discoverBrowserWsUrl(existingInfo.port);
824
+ const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
825
+ verifyBrowserIdentity(browser, profile.browser, existingInfo.port);
330
826
  const cdp = new CDPClient();
331
827
  await cdp.connect(wsUrl);
332
828
  await this.enableDomains(cdp);
@@ -346,7 +842,10 @@ export class BrowserService {
346
842
  if (conn)
347
843
  return conn;
348
844
  }
349
- catch {
845
+ catch (err) {
846
+ if (err instanceof Error && err.message.startsWith('Browser identity mismatch')) {
847
+ throw err;
848
+ }
350
849
  // Try next endpoint
351
850
  }
352
851
  }
@@ -393,7 +892,8 @@ export class BrowserService {
393
892
  }
394
893
  if (url.protocol === 'http:' || url.protocol === 'https:') {
395
894
  const port = parseInt(url.port || (url.protocol === 'https:' ? '443' : '80'), 10);
396
- const wsUrl = await discoverBrowserWsUrl(port, url.hostname);
895
+ const { wsUrl, browser } = await discoverBrowserWsUrl(port, url.hostname);
896
+ verifyBrowserIdentity(browser, profile.browser, port, url.hostname);
397
897
  const cdp = new CDPClient();
398
898
  await cdp.connect(wsUrl);
399
899
  await this.enableDomains(cdp);
@@ -411,30 +911,30 @@ export class BrowserService {
411
911
  async enableDomains(cdp) {
412
912
  await cdp.send('Target.setDiscoverTargets', { discover: true });
413
913
  }
414
- async createTaskWindow(conn, _taskId) {
415
- 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
416
918
  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');
919
+ if (targetInfos.some((t) => t.targetId === conn.windowId && t.type === 'page')) {
920
+ return conn.windowId;
431
921
  }
922
+ // Window was closed, fall through to find/create new
432
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;
930
+ }
931
+ // First ever use - create window
433
932
  const result = (await conn.cdp.send('Target.createTarget', {
434
933
  url: 'about:blank',
435
934
  newWindow: true,
436
935
  }));
437
- return { windowTargetId: result.targetId };
936
+ conn.windowId = result.targetId;
937
+ return result.targetId;
438
938
  }
439
939
  async findTask(taskId, profileName) {
440
940
  if (profileName) {
@@ -458,37 +958,74 @@ export class BrowserService {
458
958
  }
459
959
  async getTabsForTask(cdp, task) {
460
960
  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);
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;
474
974
  }
475
975
  async getProfileStatus(profileName) {
476
976
  const conn = this.connections.get(profileName);
477
977
  if (!conn)
478
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
+ }
479
988
  const tasks = [];
480
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
+ }
481
1011
  tasks.push({
482
1012
  id: task.id,
483
- tabCount: task.tabIds.length,
1013
+ name: task.name,
1014
+ tabCount: Object.keys(task.tabs).length,
1015
+ currentTabId: task.currentTabId,
484
1016
  createdAt: task.createdAt,
1017
+ tabs: tabs.length > 0 ? tabs : undefined,
1018
+ domains: domainSet.size > 0 ? Array.from(domainSet) : undefined,
485
1019
  });
486
1020
  }
1021
+ const profile = await getProfile(profileName);
1022
+ const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
487
1023
  return {
488
1024
  name: profileName,
489
1025
  running: true,
490
1026
  port: conn.port,
491
1027
  pid: conn.pid,
1028
+ configuredPort: configuredPort !== conn.port ? configuredPort : undefined,
492
1029
  tasks,
493
1030
  };
494
1031
  }
@@ -528,6 +1065,76 @@ export class BrowserService {
528
1065
  return new Map();
529
1066
  }
530
1067
  const state = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
531
- 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
+ }
532
1139
  }
533
1140
  }