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