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