@phnx-labs/agents-cli 1.22.40 → 1.22.42
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 +36 -0
- package/README.md +14 -3
- package/dist/bin/agents +0 -0
- package/dist/bootstrap.js +0 -1
- package/dist/cli/command-registry.d.ts +2 -0
- package/dist/cli/command-registry.js +5 -2
- package/dist/commands/accounts.d.ts +29 -0
- package/dist/commands/accounts.js +220 -29
- package/dist/commands/alias.js +1 -1
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.js +104 -0
- package/dist/commands/browser-sessions-picker.js +42 -62
- package/dist/commands/browser.d.ts +15 -0
- package/dist/commands/browser.js +145 -78
- package/dist/commands/computer-actions.d.ts +1 -1
- package/dist/commands/computer-actions.js +1 -1
- package/dist/commands/computer-sessions-picker.js +32 -55
- package/dist/commands/computer.d.ts +1 -1
- package/dist/commands/computer.js +2 -2
- package/dist/commands/doctor.d.ts +1 -1
- package/dist/commands/doctor.js +3 -3
- package/dist/commands/feed.js +4 -12
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/import.js +1 -1
- package/dist/commands/insights.js +163 -85
- package/dist/commands/inspect.js +2 -2
- package/dist/commands/mine.js +1 -1
- package/dist/commands/org.d.ts +6 -0
- package/dist/commands/org.js +174 -0
- package/dist/commands/packages.js +1 -1
- package/dist/commands/prune.js +1 -1
- package/dist/commands/routines.js +1 -1
- package/dist/commands/run-account-picker.d.ts +19 -0
- package/dist/commands/run-account-picker.js +81 -0
- package/dist/commands/secrets.js +87 -97
- package/dist/commands/sessions-picker-factory.d.ts +40 -0
- package/dist/commands/sessions-picker-factory.js +67 -0
- package/dist/commands/sessions-picker.d.ts +1 -0
- package/dist/commands/sessions-picker.js +27 -5
- package/dist/commands/sessions-share.d.ts +25 -0
- package/dist/commands/sessions-share.js +166 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/setup-browser.js +5 -5
- package/dist/commands/setup-mine.js +1 -1
- package/dist/commands/setup-preferences.js +4 -4
- package/dist/commands/setup.js +5 -3
- package/dist/commands/sync.js +1 -1
- package/dist/commands/versions.js +1 -1
- package/dist/commands/view.js +1 -1
- package/dist/commands/webhook.js +14 -9
- package/dist/lib/account-capabilities.d.ts +10 -0
- package/dist/lib/account-capabilities.js +34 -0
- package/dist/lib/agent-spec/agents.d.ts +584 -0
- package/dist/lib/agent-spec/agents.js +3127 -0
- package/dist/lib/agent-spec/provider.js +1 -1
- package/dist/lib/agents.d.ts +2 -584
- package/dist/lib/agents.js +2 -3127
- package/dist/lib/browser/cdp.js +4 -0
- package/dist/lib/browser/chrome.js +12 -1
- package/dist/lib/browser/drivers/local.d.ts +8 -2
- package/dist/lib/browser/drivers/local.js +8 -2
- package/dist/lib/browser/drivers/ssh.d.ts +8 -2
- package/dist/lib/browser/drivers/ssh.js +11 -4
- package/dist/lib/browser/hygiene.d.ts +1 -1
- package/dist/lib/browser/hygiene.js +5 -1
- package/dist/lib/browser/profiles.d.ts +77 -4
- package/dist/lib/browser/profiles.js +121 -12
- package/dist/lib/browser/runtime-state.d.ts +11 -5
- package/dist/lib/browser/runtime-state.js +13 -9
- package/dist/lib/browser/service.d.ts +91 -30
- package/dist/lib/browser/service.js +342 -207
- package/dist/lib/browser/types.d.ts +96 -4
- package/dist/lib/browser/types.js +51 -0
- package/dist/lib/catchup.js +1 -1
- package/dist/lib/computer/actions.d.ts +1 -1
- package/dist/lib/computer/actions.js +1 -1
- package/dist/lib/{computer-rpc.js → computer/computer-rpc.js} +5 -5
- package/dist/lib/computer/dispatch.d.ts +1 -1
- package/dist/lib/computer/download.d.ts +1 -1
- package/dist/lib/computer/download.js +3 -3
- package/dist/lib/{ssh-tunnel.d.ts → computer/ssh-tunnel.d.ts} +1 -1
- package/dist/lib/{ssh-tunnel.js → computer/ssh-tunnel.js} +12 -12
- package/dist/lib/daemon/daemon.js +1 -1
- package/dist/lib/{runner.d.ts → daemon/runner.d.ts} +10 -10
- package/dist/lib/{runner.js → daemon/runner.js} +59 -118
- package/dist/lib/daemon-webhooks.d.ts +3 -1
- package/dist/lib/daemon-webhooks.js +12 -7
- package/dist/lib/device-config.js +1 -1
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/doctor-findings.js +3 -3
- package/dist/lib/devices/fleet-inventory.js +1 -1
- package/dist/lib/devices/harness-inventory.js +1 -1
- package/dist/lib/devices/registry.js +4 -22
- package/dist/lib/doctor-diff.d.ts +1 -1
- package/dist/lib/doctor-diff.js +1 -1
- package/dist/lib/drift.js +1 -1
- package/dist/lib/entitlement.d.ts +31 -0
- package/dist/lib/entitlement.js +137 -0
- package/dist/lib/exec.js +39 -202
- package/dist/lib/feed/feed.js +2 -8
- package/dist/lib/fs-atomic.d.ts +7 -0
- package/dist/lib/fs-atomic.js +9 -0
- package/dist/lib/harness/adapter.d.ts +160 -0
- package/dist/lib/harness/adapter.js +47 -0
- package/dist/lib/harness/adapters/claude.d.ts +2 -0
- package/dist/lib/harness/adapters/claude.js +110 -0
- package/dist/lib/harness/adapters/codex.d.ts +2 -0
- package/dist/lib/harness/adapters/codex.js +83 -0
- package/dist/lib/harness/adapters/copilot.d.ts +2 -0
- package/dist/lib/harness/adapters/copilot.js +23 -0
- package/dist/lib/harness/adapters/cursor.d.ts +2 -0
- package/dist/lib/harness/adapters/cursor.js +38 -0
- package/dist/lib/harness/adapters/droid.d.ts +2 -0
- package/dist/lib/harness/adapters/droid.js +17 -0
- package/dist/lib/harness/adapters/grok.d.ts +2 -0
- package/dist/lib/harness/adapters/grok.js +14 -0
- package/dist/lib/harness/adapters/kimi.d.ts +2 -0
- package/dist/lib/harness/adapters/kimi.js +49 -0
- package/dist/lib/harness/adapters/muse.d.ts +2 -0
- package/dist/lib/harness/adapters/muse.js +44 -0
- package/dist/lib/harness/adapters/opencode.d.ts +2 -0
- package/dist/lib/harness/adapters/opencode.js +14 -0
- package/dist/lib/harness/exec-config-version.d.ts +8 -0
- package/dist/lib/harness/exec-config-version.js +22 -0
- package/dist/lib/harness/index.d.ts +1 -0
- package/dist/lib/harness/index.js +34 -0
- package/dist/lib/{hooks.d.ts → hooks/install.d.ts} +1 -1
- package/dist/lib/{hooks.js → hooks/install.js} +11 -11
- package/dist/lib/import.js +1 -1
- package/dist/lib/installations/index.d.ts +4 -4
- package/dist/lib/installations/index.js +3 -4
- package/dist/lib/installations/migrate.js +6 -6
- package/dist/lib/{shims.d.ts → installations/shims.d.ts} +2 -2
- package/dist/lib/{shims.js → installations/shims.js} +12 -114
- package/dist/lib/installations/store.d.ts +141 -3
- package/dist/lib/installations/store.js +475 -5
- package/dist/lib/installations/versions.d.ts +1 -126
- package/dist/lib/installations/versions.js +5 -465
- package/dist/lib/isolation-boundary-report.d.ts +1 -1
- package/dist/lib/isolation-boundary-report.js +1 -1
- package/dist/lib/mailbox-gc.js +2 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/monitors/dispatch.d.ts +1 -1
- package/dist/lib/monitors/dispatch.js +2 -2
- package/dist/lib/monitors/state.d.ts +1 -1
- package/dist/lib/observe-aliases.d.ts +2 -2
- package/dist/lib/observe-aliases.js +2 -11
- package/dist/lib/plugins/plugins.js +1 -1
- package/dist/lib/plugins/skills.js +1 -1
- package/dist/lib/prix-account.d.ts +158 -0
- package/dist/lib/prix-account.js +214 -0
- package/dist/lib/project-key.js +7 -0
- package/dist/lib/project-launch.js +1 -1
- package/dist/lib/refresh.js +2 -2
- package/dist/lib/resource-inventory.d.ts +1 -1
- package/dist/lib/resource-inventory.js +1 -1
- package/dist/lib/rules/rules.js +1 -1
- package/dist/lib/scheduling/routines.js +1 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +1 -1
- package/dist/lib/secrets/headless.d.ts +16 -0
- package/dist/lib/secrets/headless.js +21 -0
- package/dist/lib/secrets/remote.d.ts +9 -7
- package/dist/lib/secrets/remote.js +18 -9
- package/dist/lib/self-heal/checks/hook-runtime.js +1 -1
- package/dist/lib/self-heal/checks/path.js +1 -1
- package/dist/lib/self-heal/checks/shadowing.js +1 -1
- package/dist/lib/self-heal/checks/shims.js +1 -1
- package/dist/lib/session/discover.d.ts +39 -0
- package/dist/lib/session/discover.js +3 -3
- package/dist/lib/session/parse.js +24 -42
- package/dist/lib/session/recovery.js +1 -1
- package/dist/lib/session/share-html.d.ts +55 -0
- package/dist/lib/session/share-html.js +319 -0
- package/dist/lib/settings-manifest.d.ts +2 -0
- package/dist/lib/settings-manifest.js +81 -3
- package/dist/lib/share/publish.d.ts +38 -0
- package/dist/lib/share/publish.js +81 -8
- package/dist/lib/staleness/prune.d.ts +1 -1
- package/dist/lib/staleness/prune.js +1 -1
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +2 -1
- package/dist/lib/startup/command-registry.js +5 -3
- package/dist/lib/teams/agents.js +2 -19
- package/dist/lib/teams/parsers.js +20 -26
- package/dist/lib/teams/registry.js +2 -21
- package/dist/lib/triggers/handlers.d.ts +37 -2
- package/dist/lib/triggers/handlers.js +57 -6
- package/dist/lib/triggers/webhook.d.ts +80 -6
- package/dist/lib/triggers/webhook.js +128 -4
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/uninstall.js +1 -1
- package/dist/lib/version.d.ts +9 -2
- package/dist/lib/version.js +11 -9
- package/dist/lib/wrap.d.ts +33 -0
- package/dist/lib/wrap.js +70 -0
- package/package.json +1 -1
- /package/dist/lib/{computer-rpc.d.ts → computer/computer-rpc.d.ts} +0 -0
|
@@ -8,7 +8,7 @@ import { connectLocal } from './drivers/local.js';
|
|
|
8
8
|
import { connectSSH, shellQuote } from './drivers/ssh.js';
|
|
9
9
|
import { clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
|
|
10
10
|
import { resolveDomainSkill } from './domain-skills.js';
|
|
11
|
-
import { generateTaskId, generateShortId, } from './types.js';
|
|
11
|
+
import { generateTaskId, generateShortId, connectionKey, asConnectionKey, parseConnectionKey, keyBelongsToProfile, } from './types.js';
|
|
12
12
|
import { reapAbandonedTasks, resolveLiveIdentities, taskOwnerIsGone, } from './hygiene.js';
|
|
13
13
|
import { taskMatchesCaller } from './caller-identity.js';
|
|
14
14
|
import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
|
|
@@ -243,6 +243,17 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
|
|
|
243
243
|
export function actionable(...lines) {
|
|
244
244
|
return lines.filter((l) => l != null && l !== '').join('\n');
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Arc answers `Browser.getVersion` (so a connection succeeds) but exposes zero
|
|
248
|
+
* CDP page targets via every discovery method and CRASHES when a new tab is
|
|
249
|
+
* requested via `Target.createTarget` (verified live, PR #2778). It is therefore
|
|
250
|
+
* not drivable. This is the single clear, actionable error every tab-creating
|
|
251
|
+
* path throws instead of crashing the user's Arc window.
|
|
252
|
+
*/
|
|
253
|
+
export function arcNotDrivableError(profileName) {
|
|
254
|
+
const scope = profileName ? ` (profile "${profileName}")` : '';
|
|
255
|
+
return new Error(actionable(`Browser "arc"${scope} is not drivable: Arc exposes no CDP page targets and`, 'crashes when a new tab is requested, so `agents browser` cannot control it.', 'Use a Chromium-family browser instead — Comet, Chrome, Chromium, or Brave:', ' agents browser profiles create <name> --browser comet'));
|
|
256
|
+
}
|
|
246
257
|
/** Derive a human label from an explicit title or a navigated URL. */
|
|
247
258
|
export function deriveTaskLabel(opts) {
|
|
248
259
|
if (opts.title?.trim())
|
|
@@ -261,14 +272,6 @@ export function deriveTaskLabel(opts) {
|
|
|
261
272
|
}
|
|
262
273
|
return opts.existing ?? 'untitled';
|
|
263
274
|
}
|
|
264
|
-
/**
|
|
265
|
-
* True when `key` is the bare profile name or one of its composite endpoint
|
|
266
|
-
* keys (`name@endpoint`). Used by status/stop so `comet-local` matches
|
|
267
|
-
* `comet-local@endpoint-0`.
|
|
268
|
-
*/
|
|
269
|
-
export function connectionKeyMatchesProfile(key, profileName) {
|
|
270
|
-
return key === profileName || key.startsWith(`${profileName}@`) || key.startsWith(`${profileName}.`);
|
|
271
|
-
}
|
|
272
275
|
/**
|
|
273
276
|
* Resolve the identity stamped on a task at start: WHO (`owner`) and WHICH run
|
|
274
277
|
* (`launchId`). The forwarded values come from the caller's own CLI process and
|
|
@@ -290,6 +293,12 @@ export class BrowserService {
|
|
|
290
293
|
'rush-app': 'rush-app-',
|
|
291
294
|
'rush-cli': 'rush-cli-',
|
|
292
295
|
};
|
|
296
|
+
/**
|
|
297
|
+
* Live browsers, keyed by {@link ConnectionKey} — NOT by profile name. The
|
|
298
|
+
* branded key type is what makes `connections.get(someProfileName)` (the
|
|
299
|
+
* RUSH-2709 miss that made `status --profile <name>` return empty for a
|
|
300
|
+
* running `<name>@endpoint-0`) fail to compile.
|
|
301
|
+
*/
|
|
293
302
|
connections = new Map();
|
|
294
303
|
forkingProfiles = new Set();
|
|
295
304
|
// Per-task storage for console, errors, network, downloads
|
|
@@ -308,19 +317,18 @@ export class BrowserService {
|
|
|
308
317
|
throw new Error(`Profile "${profileName}" not found`);
|
|
309
318
|
}
|
|
310
319
|
// Pick the endpoint preset. Throws with the candidate list if the user
|
|
311
|
-
// passed an unknown name. The
|
|
312
|
-
//
|
|
313
|
-
//
|
|
320
|
+
// passed an unknown name. The runtime key `<profile>@<endpoint>` is what
|
|
321
|
+
// the connection map + per-profile runtime dirs are keyed on, so a single
|
|
322
|
+
// YAML profile can run at multiple endpoints concurrently.
|
|
314
323
|
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
324
|
+
// `effectiveProfile.name` stays BARE. Overwriting it with the runtime key
|
|
325
|
+
// here (pre-RUSH-2709) is what leaked `comet-local@endpoint-0` into every
|
|
326
|
+
// listing and made `status --profile comet-local` miss its own browser.
|
|
327
|
+
// The key travels as its own argument instead.
|
|
317
328
|
const resolved = resolveEndpoint(profile, opts.endpointName);
|
|
318
|
-
const composite =
|
|
329
|
+
const composite = connectionKey(profileName, resolved.name);
|
|
319
330
|
const effectiveProfile = {
|
|
320
331
|
...profile,
|
|
321
|
-
// Runtime/connect path key — NOT the bare YAML name. Connections and
|
|
322
|
-
// tasks.json live under this composite so multi-endpoint is safe.
|
|
323
|
-
name: composite,
|
|
324
332
|
binary: resolved.binary,
|
|
325
333
|
targetFilter: resolved.targetFilter,
|
|
326
334
|
};
|
|
@@ -338,7 +346,7 @@ export class BrowserService {
|
|
|
338
346
|
}
|
|
339
347
|
const taskLabel = deriveTaskLabel({ title: opts.title, url: opts.url });
|
|
340
348
|
let conn = this.connections.get(composite);
|
|
341
|
-
let
|
|
349
|
+
let effectiveKey = composite;
|
|
342
350
|
// If we have a cached connection, confirm it's still usable before any
|
|
343
351
|
// caller relies on it. A browser killed externally (Cmd-Q, crash, or a
|
|
344
352
|
// user clearing the profile by hand) leaves a closed WebSocket here.
|
|
@@ -361,7 +369,7 @@ export class BrowserService {
|
|
|
361
369
|
const existingFork = this.findAvailableFork(composite);
|
|
362
370
|
if (existingFork) {
|
|
363
371
|
conn = existingFork.conn;
|
|
364
|
-
|
|
372
|
+
effectiveKey = existingFork.name;
|
|
365
373
|
}
|
|
366
374
|
else {
|
|
367
375
|
throw new Error(`Fork in progress but no available fork found for "${composite}"`);
|
|
@@ -370,9 +378,9 @@ export class BrowserService {
|
|
|
370
378
|
else {
|
|
371
379
|
this.forkingProfiles.add(composite);
|
|
372
380
|
try {
|
|
373
|
-
const { forkName, connection } = await this.forkElectronProfile(effectiveProfile);
|
|
381
|
+
const { forkName, connection } = await this.forkElectronProfile(effectiveProfile, composite);
|
|
374
382
|
conn = connection;
|
|
375
|
-
|
|
383
|
+
effectiveKey = forkName;
|
|
376
384
|
}
|
|
377
385
|
finally {
|
|
378
386
|
this.forkingProfiles.delete(composite);
|
|
@@ -380,14 +388,14 @@ export class BrowserService {
|
|
|
380
388
|
}
|
|
381
389
|
}
|
|
382
390
|
else if (!conn) {
|
|
383
|
-
conn = await this.connectProfile(effectiveProfile, resolved.target);
|
|
384
|
-
conn.
|
|
385
|
-
conn.
|
|
391
|
+
conn = await this.connectProfile(effectiveProfile, resolved.target, composite);
|
|
392
|
+
conn.key = composite;
|
|
393
|
+
conn.profile = profileName;
|
|
386
394
|
this.connections.set(composite, conn);
|
|
387
395
|
await this.applyDefaultDownloadBehavior(conn, composite);
|
|
388
396
|
}
|
|
389
|
-
if (conn && !conn.
|
|
390
|
-
conn.
|
|
397
|
+
if (conn && !conn.profile)
|
|
398
|
+
conn.profile = profileName;
|
|
391
399
|
// Browsers launch with --no-startup-window (session-cookie persistence,
|
|
392
400
|
// see launchBrowser), so a bare `start` with no --url would otherwise
|
|
393
401
|
// leave the user staring at a process with zero windows. Recreate the
|
|
@@ -404,9 +412,9 @@ export class BrowserService {
|
|
|
404
412
|
if (!opts.url && !conn.electron) {
|
|
405
413
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
406
414
|
if (!targetInfos.some((t) => t.type === 'page')) {
|
|
407
|
-
const created =
|
|
415
|
+
const created = await this.createPageTarget(conn, {
|
|
408
416
|
url: 'about:blank',
|
|
409
|
-
})
|
|
417
|
+
});
|
|
410
418
|
startupBlankTargetId = created.targetId;
|
|
411
419
|
this.invalidateTargetCache(conn);
|
|
412
420
|
}
|
|
@@ -416,7 +424,7 @@ export class BrowserService {
|
|
|
416
424
|
id: taskId,
|
|
417
425
|
name: taskName,
|
|
418
426
|
label: taskLabel,
|
|
419
|
-
profile:
|
|
427
|
+
profile: effectiveKey,
|
|
420
428
|
tabs: {},
|
|
421
429
|
currentTabId: undefined,
|
|
422
430
|
createdAt: now,
|
|
@@ -445,7 +453,7 @@ export class BrowserService {
|
|
|
445
453
|
}
|
|
446
454
|
}
|
|
447
455
|
conn.tasks.set(taskName, task);
|
|
448
|
-
await this.saveTaskState(
|
|
456
|
+
await this.saveTaskState(effectiveKey, conn.tasks);
|
|
449
457
|
// Durable identity, written ONCE at start and never deleted (RUSH-2549).
|
|
450
458
|
// tasks.json above stays live state: `stop` drops the task from that map, so
|
|
451
459
|
// anything recorded only there is gone the moment the task ends -- which is
|
|
@@ -459,22 +467,23 @@ export class BrowserService {
|
|
|
459
467
|
try {
|
|
460
468
|
recordBrowserSession({
|
|
461
469
|
task: taskName,
|
|
462
|
-
profile:
|
|
470
|
+
profile: effectiveKey,
|
|
463
471
|
sessionId: task.sessionId,
|
|
464
472
|
launchId: task.launchId,
|
|
465
473
|
actor: task.owner,
|
|
466
474
|
startedAt: task.createdAt,
|
|
467
|
-
captureDir: getProfileSessionsDir(
|
|
475
|
+
captureDir: getProfileSessionsDir(effectiveKey, taskName),
|
|
468
476
|
});
|
|
469
477
|
}
|
|
470
478
|
catch {
|
|
471
479
|
// Recording is best-effort; the task itself is live either way.
|
|
472
480
|
}
|
|
473
|
-
|
|
481
|
+
// Feed events and the usage rollup are read by humans: bare name only.
|
|
482
|
+
emit('browser.launch', { profile: profileName, task: taskName, pid: conn.pid });
|
|
474
483
|
void import('../analytics/usage-db.js').then(({ recordUsage }) => {
|
|
475
484
|
recordUsage({
|
|
476
485
|
kind: 'browser',
|
|
477
|
-
name:
|
|
486
|
+
name: profileName,
|
|
478
487
|
event: 'launch',
|
|
479
488
|
source: 'browser',
|
|
480
489
|
meta: { task: taskName },
|
|
@@ -485,19 +494,16 @@ export class BrowserService {
|
|
|
485
494
|
let tabId;
|
|
486
495
|
if (opts.url && !conn.electron) {
|
|
487
496
|
const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
|
|
488
|
-
const targetId = adopted ??
|
|
489
|
-
(await conn.cdp.send('Target.createTarget', {
|
|
490
|
-
url: opts.url,
|
|
491
|
-
})).targetId;
|
|
497
|
+
const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
|
|
492
498
|
const shortId = generateShortId();
|
|
493
499
|
task.tabs[shortId] = targetId;
|
|
494
500
|
task.currentTabId = shortId;
|
|
495
501
|
this.invalidateTargetCache(conn);
|
|
496
|
-
await this.saveTaskState(
|
|
502
|
+
await this.saveTaskState(effectiveKey, conn.tasks);
|
|
497
503
|
tabId = shortId;
|
|
498
504
|
}
|
|
499
505
|
else if (opts.url && conn.electron) {
|
|
500
|
-
const result = await this.navigate(taskName, opts.url,
|
|
506
|
+
const result = await this.navigate(taskName, opts.url, effectiveKey);
|
|
501
507
|
tabId = result.tabId;
|
|
502
508
|
}
|
|
503
509
|
// Domain-skill discovery: when a URL is supplied, look up site-specific
|
|
@@ -509,7 +515,7 @@ export class BrowserService {
|
|
|
509
515
|
if (resolved)
|
|
510
516
|
skill = resolved;
|
|
511
517
|
}
|
|
512
|
-
return { task: taskId, name: taskName, tabId, profile:
|
|
518
|
+
return { task: taskId, name: taskName, tabId, profile: profileName, key: effectiveKey, skill };
|
|
513
519
|
}
|
|
514
520
|
/**
|
|
515
521
|
* Reclaim a live page already showing `url` from an ABANDONED task, instead
|
|
@@ -543,6 +549,50 @@ export class BrowserService {
|
|
|
543
549
|
* page that has since redirected elsewhere simply does not match, and the
|
|
544
550
|
* caller opens a tab as before.
|
|
545
551
|
*/
|
|
552
|
+
/** Is this CDP target already registered to any task on the connection? */
|
|
553
|
+
targetIsClaimed(conn, targetId) {
|
|
554
|
+
for (const t of conn.tasks.values()) {
|
|
555
|
+
if (Object.values(t.tabs).includes(targetId))
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The CDP targets a task may CLOSE — its own tabs minus any it merely
|
|
562
|
+
* borrowed. A borrowed tab existed before the task did (see `Task.borrowedTabs`),
|
|
563
|
+
* so closing it would remove something the task never opened.
|
|
564
|
+
*/
|
|
565
|
+
closeableTargetIds(task) {
|
|
566
|
+
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
567
|
+
return Object.entries(task.tabs)
|
|
568
|
+
.filter(([shortId]) => !borrowed.has(shortId))
|
|
569
|
+
.map(([, cdpId]) => cdpId);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* A page target that `navigate` may take over on a browser which cannot create
|
|
573
|
+
* tabs (Arc). Deliberately narrow: only a tab ALREADY showing the requested URL
|
|
574
|
+
* -- re-showing that document there is what the caller asked for -- or an empty
|
|
575
|
+
* new-tab page. A target any task already owns is left alone. Note the honest
|
|
576
|
+
* limit: reusing a matching tab still RELOADS it, discarding scroll position and
|
|
577
|
+
* unsaved form state. Arc cannot open a tab at all, so some intrusion is
|
|
578
|
+
* unavoidable to show the document; a reload is the smallest one, and the tab is
|
|
579
|
+
* borrowed rather than owned so it is never closed.
|
|
580
|
+
*/
|
|
581
|
+
async pickReusableTargetWithoutCreate(conn, url) {
|
|
582
|
+
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
583
|
+
const owned = new Set();
|
|
584
|
+
for (const t of conn.tasks.values()) {
|
|
585
|
+
for (const id of Object.values(t.tabs))
|
|
586
|
+
owned.add(id);
|
|
587
|
+
}
|
|
588
|
+
const free = targetInfos.filter((t) => t.type === 'page' && !owned.has(t.targetId));
|
|
589
|
+
const wanted = canonicalTabUrl(url);
|
|
590
|
+
const sameUrl = free.find((t) => canonicalTabUrl(t.url) === wanted);
|
|
591
|
+
if (sameUrl)
|
|
592
|
+
return sameUrl.targetId;
|
|
593
|
+
const BLANK = new Set(['', 'about:blank', 'about:newtab', 'chrome://newtab/', 'chrome://new-tab-page/']);
|
|
594
|
+
return free.find((t) => BLANK.has(t.url))?.targetId;
|
|
595
|
+
}
|
|
546
596
|
async adoptTabShowing(conn, url) {
|
|
547
597
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
548
598
|
const wanted = canonicalTabUrl(url);
|
|
@@ -553,7 +603,15 @@ export class BrowserService {
|
|
|
553
603
|
return undefined;
|
|
554
604
|
const candidates = [];
|
|
555
605
|
for (const task of conn.tasks.values()) {
|
|
606
|
+
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
556
607
|
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
608
|
+
// A BORROWED tab is not ours to hand on. The dead task never opened it
|
|
609
|
+
// (Task.borrowedTabs) -- reclaiming it would register a pre-existing tab
|
|
610
|
+
// as a fresh task's own, and that task's `done` would close it. Exactly
|
|
611
|
+
// the "adopting it would make this task's `done` close the user's tab"
|
|
612
|
+
// case above, arriving one hop later via reclaim instead of directly.
|
|
613
|
+
if (borrowed.has(shortId))
|
|
614
|
+
continue;
|
|
557
615
|
if (matching.has(cdpId))
|
|
558
616
|
candidates.push({ task, shortId, targetId: cdpId });
|
|
559
617
|
}
|
|
@@ -602,7 +660,7 @@ export class BrowserService {
|
|
|
602
660
|
const out = [];
|
|
603
661
|
for (const [key, conn] of this.connections) {
|
|
604
662
|
for (const task of conn.tasks.values()) {
|
|
605
|
-
out.push({ profile: conn.
|
|
663
|
+
out.push({ profile: conn.key ?? key, task });
|
|
606
664
|
}
|
|
607
665
|
}
|
|
608
666
|
return out;
|
|
@@ -623,7 +681,7 @@ export class BrowserService {
|
|
|
623
681
|
catch {
|
|
624
682
|
// findTask throws when truly gone; fall through to the miss return.
|
|
625
683
|
}
|
|
626
|
-
for (const [
|
|
684
|
+
for (const [key, conn] of this.connections) {
|
|
627
685
|
const task = this.lookupTaskOnConn(conn, taskName);
|
|
628
686
|
if (task) {
|
|
629
687
|
// Map key may be task.name, not the caller-supplied handle.
|
|
@@ -651,8 +709,9 @@ export class BrowserService {
|
|
|
651
709
|
}
|
|
652
710
|
// Save to history before closing
|
|
653
711
|
await this.saveToHistory(task, Array.from(domains));
|
|
654
|
-
// Close task's tabs (not the window - it's shared
|
|
655
|
-
|
|
712
|
+
// Close task's tabs (not the window - it's shared, and not a tab the
|
|
713
|
+
// task merely borrowed - that one was open before the task existed).
|
|
714
|
+
await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
|
|
656
715
|
// Tab already closed
|
|
657
716
|
})));
|
|
658
717
|
for (const cdpId of Object.values(task.tabs)) {
|
|
@@ -660,12 +719,14 @@ export class BrowserService {
|
|
|
660
719
|
}
|
|
661
720
|
this.invalidateTargetCache(conn);
|
|
662
721
|
conn.tasks.delete(mapKey);
|
|
663
|
-
await this.saveTaskState(
|
|
664
|
-
|
|
722
|
+
await this.saveTaskState(key, conn.tasks);
|
|
723
|
+
// Feed + usage rows are read by humans: bare profile name (RUSH-2709).
|
|
724
|
+
const bareProfile = conn.profile ?? parseConnectionKey(key).profile;
|
|
725
|
+
emit('browser.close', { profile: bareProfile, task: task.name });
|
|
665
726
|
void import('../analytics/usage-db.js').then(({ recordUsage }) => {
|
|
666
727
|
recordUsage({
|
|
667
728
|
kind: 'browser',
|
|
668
|
-
name:
|
|
729
|
+
name: bareProfile,
|
|
669
730
|
event: 'close',
|
|
670
731
|
source: 'browser',
|
|
671
732
|
meta: { task: task.name },
|
|
@@ -675,10 +736,10 @@ export class BrowserService {
|
|
|
675
736
|
conn.cdp.close();
|
|
676
737
|
killChrome(conn.pid);
|
|
677
738
|
conn.cleanup?.();
|
|
678
|
-
this.connections.delete(
|
|
679
|
-
clearProfileRuntime(
|
|
739
|
+
this.connections.delete(key);
|
|
740
|
+
clearProfileRuntime(key);
|
|
680
741
|
}
|
|
681
|
-
return { ok: true, profile:
|
|
742
|
+
return { ok: true, profile: bareProfile };
|
|
682
743
|
}
|
|
683
744
|
}
|
|
684
745
|
return { ok: false };
|
|
@@ -686,16 +747,15 @@ export class BrowserService {
|
|
|
686
747
|
async done(taskName) {
|
|
687
748
|
return this.stop(taskName);
|
|
688
749
|
}
|
|
689
|
-
async stopProfile(
|
|
690
|
-
// Connections are keyed by the
|
|
691
|
-
//
|
|
692
|
-
// A plain `connections.get(
|
|
693
|
-
// connection, so `cleanup()` never ran — leaving the SSH tunnel
|
|
694
|
-
// Windows, the WMI-spawned browser orphaned on the remote host after
|
|
695
|
-
// `browser stop --profile` (#559).
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
const keys = [...this.connections.keys()].filter((k) => k === profileName || k.startsWith(`${profileName}@`));
|
|
750
|
+
async stopProfile(profileRef) {
|
|
751
|
+
// Connections are keyed by the runtime key `<profile>@<endpoint>` (see
|
|
752
|
+
// start()) while callers pass the bare profile name (or, occasionally, an
|
|
753
|
+
// exact key). A plain `connections.get(profileRef)` therefore missed every
|
|
754
|
+
// real remote connection, so `cleanup()` never ran — leaving the SSH tunnel
|
|
755
|
+
// and, on Windows, the WMI-spawned browser orphaned on the remote host after
|
|
756
|
+
// every `browser stop --profile` (#559). Same single rule as findTask and
|
|
757
|
+
// status: exact key, else every key belonging to that profile.
|
|
758
|
+
const keys = [...this.connections.keys()].filter((k) => k === profileRef || keyBelongsToProfile(k, profileRef));
|
|
699
759
|
for (const key of keys) {
|
|
700
760
|
const conn = this.connections.get(key);
|
|
701
761
|
if (!conn)
|
|
@@ -709,7 +769,7 @@ export class BrowserService {
|
|
|
709
769
|
// Kill stale processes and clean runtime dirs for every composite and
|
|
710
770
|
// fork entry belonging to this profile (including `.N` forks left by
|
|
711
771
|
// earlier daemon sessions that the connection loop above didn't cover).
|
|
712
|
-
for (const dir of listProfileCacheDirs(
|
|
772
|
+
for (const dir of listProfileCacheDirs(parseConnectionKey(profileRef).profile)) {
|
|
713
773
|
const dirName = path.basename(dir);
|
|
714
774
|
const meta = readProfileRuntimeMeta(dirName);
|
|
715
775
|
if (meta?.pid && meta.pid !== 0 && isProcessAlive(meta.pid, meta.command)) {
|
|
@@ -718,8 +778,8 @@ export class BrowserService {
|
|
|
718
778
|
clearProfileRuntime(dirName);
|
|
719
779
|
}
|
|
720
780
|
}
|
|
721
|
-
async navigate(taskId, url,
|
|
722
|
-
const { conn, task } = await this.findTask(taskId,
|
|
781
|
+
async navigate(taskId, url, profileRef) {
|
|
782
|
+
const { conn, task } = await this.findTask(taskId, profileRef);
|
|
723
783
|
this.maybeUpdateLabelFromUrl(conn, task, url);
|
|
724
784
|
// If we have a current tab, navigate in it (reuse)
|
|
725
785
|
const currentShortId = task.currentTabId;
|
|
@@ -728,7 +788,7 @@ export class BrowserService {
|
|
|
728
788
|
const sessionId = await this.getSessionId(conn, cdpTargetId);
|
|
729
789
|
await conn.cdp.send('Page.navigate', { url }, sessionId);
|
|
730
790
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
731
|
-
emit('browser.navigate', { profile: task.profile, task: task.name, url, tabId: currentShortId, created: false });
|
|
791
|
+
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
|
|
732
792
|
return { tabId: currentShortId, url, created: false };
|
|
733
793
|
}
|
|
734
794
|
// No current tab - create one
|
|
@@ -743,29 +803,77 @@ export class BrowserService {
|
|
|
743
803
|
task.tabs[shortId] = cdpTargetId;
|
|
744
804
|
task.currentTabId = shortId;
|
|
745
805
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
746
|
-
emit('browser.navigate', { profile: task.profile, task: task.name, url, tabId: shortId, created: true });
|
|
806
|
+
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
|
|
747
807
|
return { tabId: shortId, url, created: true };
|
|
748
808
|
}
|
|
809
|
+
// Arc exposes page targets and honors Page.navigate on them; what it cannot
|
|
810
|
+
// survive is Target.createTarget (#2778). createPageTarget below therefore
|
|
811
|
+
// refuses outright, so the document never gets shown AT ALL and callers fall
|
|
812
|
+
// back to a raw `open` -- a new tab every call, which is the tab-spam #2779
|
|
813
|
+
// set out to end. Reuse in place instead, the same shape as the Electron
|
|
814
|
+
// branch above. Measured against a live Arc (#2786): 33 page targets,
|
|
815
|
+
// Page.navigate reused one, tab count unchanged, no crash.
|
|
816
|
+
if (conn.browserType === 'arc') {
|
|
817
|
+
const reusable = await this.pickReusableTargetWithoutCreate(conn, url);
|
|
818
|
+
if (reusable) {
|
|
819
|
+
const shortId = generateShortId();
|
|
820
|
+
const sessionId = await this.getSessionId(conn, reusable);
|
|
821
|
+
// Check and CLAIM in one synchronous tick, with no await between them --
|
|
822
|
+
// the ordering `adoptTabShowing` uses (service.ts: the `task.tabs[shortId]
|
|
823
|
+
// !== targetId` re-check sits immediately before its `delete`). Checking
|
|
824
|
+
// and then awaiting `Page.navigate` before writing leaves exactly the gap
|
|
825
|
+
// the check exists to close: a concurrent navigate passes its own check in
|
|
826
|
+
// that window and both tasks end up driving one tab.
|
|
827
|
+
if (this.targetIsClaimed(conn, reusable)) {
|
|
828
|
+
throw arcNotDrivableError(conn.profile);
|
|
829
|
+
}
|
|
830
|
+
task.tabs[shortId] = reusable;
|
|
831
|
+
// Borrowed, not opened: this tab was in the browser before the task and
|
|
832
|
+
// must survive `done`. See Task.borrowedTabs.
|
|
833
|
+
task.borrowedTabs = [...(task.borrowedTabs ?? []), shortId];
|
|
834
|
+
task.currentTabId = shortId;
|
|
835
|
+
try {
|
|
836
|
+
await conn.cdp.send('Page.navigate', { url }, sessionId);
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
// Release the claim we took optimistically, so a failed navigate does
|
|
840
|
+
// not leave the task holding a tab it never drove.
|
|
841
|
+
delete task.tabs[shortId];
|
|
842
|
+
task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
|
|
843
|
+
if (task.currentTabId === shortId)
|
|
844
|
+
task.currentTabId = undefined;
|
|
845
|
+
throw err;
|
|
846
|
+
}
|
|
847
|
+
this.invalidateTargetCache(conn);
|
|
848
|
+
await this.saveTaskState(task.profile, conn.tasks);
|
|
849
|
+
emit('browser.navigate', {
|
|
850
|
+
profile: task.profile,
|
|
851
|
+
task: task.name,
|
|
852
|
+
url,
|
|
853
|
+
tabId: shortId,
|
|
854
|
+
created: false,
|
|
855
|
+
});
|
|
856
|
+
return { tabId: shortId, url, created: false };
|
|
857
|
+
}
|
|
858
|
+
// Nothing safe to reuse -- fall through to the actionable refusal rather
|
|
859
|
+
// than taking over a page the user is reading.
|
|
860
|
+
}
|
|
749
861
|
// Chrome: create new tab
|
|
750
|
-
const result =
|
|
751
|
-
url,
|
|
752
|
-
}));
|
|
862
|
+
const result = await this.createPageTarget(conn, { url });
|
|
753
863
|
const shortId = generateShortId();
|
|
754
864
|
task.tabs[shortId] = result.targetId;
|
|
755
865
|
task.currentTabId = shortId;
|
|
756
866
|
this.invalidateTargetCache(conn);
|
|
757
867
|
await this.saveTaskState(task.profile, conn.tasks);
|
|
758
|
-
emit('browser.navigate', { profile: task.profile, task: task.name, url, tabId: shortId, created: true });
|
|
868
|
+
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
|
|
759
869
|
return { tabId: shortId, url, created: true };
|
|
760
870
|
}
|
|
761
|
-
async tabAdd(taskId, url,
|
|
762
|
-
const { conn, task } = await this.findTask(taskId,
|
|
871
|
+
async tabAdd(taskId, url, profileRef) {
|
|
872
|
+
const { conn, task } = await this.findTask(taskId, profileRef);
|
|
763
873
|
if (conn.electron) {
|
|
764
874
|
throw new Error('Electron apps do not support opening additional tabs');
|
|
765
875
|
}
|
|
766
|
-
const result =
|
|
767
|
-
url,
|
|
768
|
-
}));
|
|
876
|
+
const result = await this.createPageTarget(conn, { url });
|
|
769
877
|
const shortId = generateShortId();
|
|
770
878
|
task.tabs[shortId] = result.targetId;
|
|
771
879
|
task.currentTabId = shortId; // new tab becomes current
|
|
@@ -837,9 +945,9 @@ export class BrowserService {
|
|
|
837
945
|
throw new Error(`Tab ${shortId} not found`);
|
|
838
946
|
return cdpId;
|
|
839
947
|
}
|
|
840
|
-
async tabs(taskId,
|
|
948
|
+
async tabs(taskId, profileRef) {
|
|
841
949
|
if (taskId) {
|
|
842
|
-
const { conn, task } = await this.findTask(taskId,
|
|
950
|
+
const { conn, task } = await this.findTask(taskId, profileRef);
|
|
843
951
|
return this.getTabsForTask(conn.cdp, task);
|
|
844
952
|
}
|
|
845
953
|
const allTabs = [];
|
|
@@ -857,9 +965,15 @@ export class BrowserService {
|
|
|
857
965
|
const shortId = await this.resolveTabHint(conn, task, tabHint);
|
|
858
966
|
const cdpId = task.tabs[shortId];
|
|
859
967
|
if (cdpId) {
|
|
860
|
-
|
|
968
|
+
// A borrowed tab is released, not closed — it outlives this task.
|
|
969
|
+
if (!(task.borrowedTabs ?? []).includes(shortId)) {
|
|
970
|
+
await conn.cdp.send('Target.closeTarget', { targetId: cdpId });
|
|
971
|
+
}
|
|
861
972
|
conn.sessionCache.delete(cdpId);
|
|
862
973
|
delete task.tabs[shortId];
|
|
974
|
+
// Drop the borrow record with the tab it names, or it outlives every tab
|
|
975
|
+
// it ever described and grows unbounded in the persisted task state.
|
|
976
|
+
task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
|
|
863
977
|
// Update currentTabId if we closed the current tab
|
|
864
978
|
if (task.currentTabId === shortId) {
|
|
865
979
|
const remaining = Object.keys(task.tabs);
|
|
@@ -868,12 +982,13 @@ export class BrowserService {
|
|
|
868
982
|
}
|
|
869
983
|
}
|
|
870
984
|
else {
|
|
871
|
-
// Close all tabs
|
|
872
|
-
await Promise.all(
|
|
985
|
+
// Close all tabs the task opened; borrowed ones are only released.
|
|
986
|
+
await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => { })));
|
|
873
987
|
for (const cdpId of Object.values(task.tabs)) {
|
|
874
988
|
conn.sessionCache.delete(cdpId);
|
|
875
989
|
}
|
|
876
990
|
task.tabs = {};
|
|
991
|
+
task.borrowedTabs = [];
|
|
877
992
|
task.currentTabId = undefined;
|
|
878
993
|
}
|
|
879
994
|
this.invalidateTargetCache(conn);
|
|
@@ -910,7 +1025,7 @@ export class BrowserService {
|
|
|
910
1025
|
return result.result.value;
|
|
911
1026
|
}
|
|
912
1027
|
async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
|
|
913
|
-
const { conn, task,
|
|
1028
|
+
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
914
1029
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
915
1030
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
916
1031
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -943,7 +1058,7 @@ export class BrowserService {
|
|
|
943
1058
|
}
|
|
944
1059
|
extension = 'jpg';
|
|
945
1060
|
}
|
|
946
|
-
const sessionsDir = getProfileSessionsDir(
|
|
1061
|
+
const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
|
|
947
1062
|
const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
|
|
948
1063
|
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
949
1064
|
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
@@ -951,7 +1066,7 @@ export class BrowserService {
|
|
|
951
1066
|
const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
|
|
952
1067
|
{ width: 0, height: 0 };
|
|
953
1068
|
emit('browser.screenshot', {
|
|
954
|
-
profile:
|
|
1069
|
+
profile: parseConnectionKey(runtimeKey).profile,
|
|
955
1070
|
task: task.name,
|
|
956
1071
|
tabId: shortId,
|
|
957
1072
|
path: finalPath,
|
|
@@ -970,7 +1085,7 @@ export class BrowserService {
|
|
|
970
1085
|
* — without it, dark-mode pages render on a blank sheet.
|
|
971
1086
|
*/
|
|
972
1087
|
async printToPdf(taskId, tabHint, outputPath) {
|
|
973
|
-
const { conn, task,
|
|
1088
|
+
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
974
1089
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
975
1090
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
976
1091
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -980,7 +1095,7 @@ export class BrowserService {
|
|
|
980
1095
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
981
1096
|
const { data } = (await conn.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true }, sessionId));
|
|
982
1097
|
const buffer = Buffer.from(data, 'base64');
|
|
983
|
-
const sessionsDir = getProfileSessionsDir(
|
|
1098
|
+
const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
|
|
984
1099
|
const automaticPath = path.join(sessionsDir, `${Date.now()}.pdf`);
|
|
985
1100
|
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
986
1101
|
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
@@ -998,7 +1113,7 @@ export class BrowserService {
|
|
|
998
1113
|
if (this.recordings.has(taskId)) {
|
|
999
1114
|
throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
|
|
1000
1115
|
}
|
|
1001
|
-
const { conn, task,
|
|
1116
|
+
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
1002
1117
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1003
1118
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1004
1119
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1014,7 +1129,7 @@ export class BrowserService {
|
|
|
1014
1129
|
throw new Error('--duration must be between 1 and 3600 seconds');
|
|
1015
1130
|
if (maxMb < 1 || maxMb > 500)
|
|
1016
1131
|
throw new Error('--max-mb must be between 1 and 500');
|
|
1017
|
-
const recordingsDir = path.join(getProfileSessionsDir(
|
|
1132
|
+
const recordingsDir = path.join(getProfileSessionsDir(runtimeKey, task.name), 'recordings');
|
|
1018
1133
|
await fs.promises.mkdir(recordingsDir, { recursive: true });
|
|
1019
1134
|
const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
|
|
1020
1135
|
// Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
|
|
@@ -1408,34 +1523,37 @@ export class BrowserService {
|
|
|
1408
1523
|
stageUpload(source) {
|
|
1409
1524
|
return { path: stageUploadFile(source) };
|
|
1410
1525
|
}
|
|
1411
|
-
|
|
1526
|
+
/**
|
|
1527
|
+
* Status for one profile (bare name) or every live profile.
|
|
1528
|
+
*
|
|
1529
|
+
* `profileName` is a {@link ProfileName} — a BARE name, never a runtime key.
|
|
1530
|
+
* Every candidate is selected by the one rule ({@link keyBelongsToProfile}),
|
|
1531
|
+
* and a scoped query that finds no live connection falls through to disk, so
|
|
1532
|
+
* `status --profile comet-local` reports the LIVE `comet-local@endpoint-0`
|
|
1533
|
+
* instead of the empty list it used to return (RUSH-2709).
|
|
1534
|
+
*/
|
|
1535
|
+
async status(profileRef) {
|
|
1412
1536
|
// Reconnect live browsers after a daemon restart so status is not empty
|
|
1413
1537
|
// while tasks.json still holds open work.
|
|
1414
1538
|
await this.rehydrateAllFromDisk();
|
|
1415
|
-
|
|
1539
|
+
// A caller who pasted a runtime key out of an older listing still gets the
|
|
1540
|
+
// profile it belongs to, rather than an empty result.
|
|
1541
|
+
const profileName = profileRef ? parseConnectionKey(profileRef).profile : undefined;
|
|
1542
|
+
const seenProfiles = new Set();
|
|
1416
1543
|
const statuses = [];
|
|
1417
|
-
// Connections are keyed by composite `<profile>@<endpoint>`. A bare
|
|
1418
|
-
// `--profile comet-local` must still list those tasks (same rule as
|
|
1419
|
-
// stopProfile). Match exact key, bareName, and composite/fork prefixes.
|
|
1420
1544
|
const candidates = profileName
|
|
1421
|
-
? [...this.connections.keys()].filter((k) =>
|
|
1422
|
-
this.connections.get(k)?.bareName === profileName)
|
|
1545
|
+
? [...this.connections.keys()].filter((k) => keyBelongsToProfile(k, profileName))
|
|
1423
1546
|
: Array.from(this.connections.keys());
|
|
1424
|
-
for (const
|
|
1425
|
-
const status = await this.getProfileStatus(
|
|
1547
|
+
for (const key of candidates) {
|
|
1548
|
+
const status = await this.getProfileStatus(key);
|
|
1426
1549
|
if (status) {
|
|
1427
1550
|
statuses.push(status);
|
|
1428
|
-
|
|
1429
|
-
if (status.name)
|
|
1430
|
-
seen.add(status.name);
|
|
1551
|
+
seenProfiles.add(status.name);
|
|
1431
1552
|
}
|
|
1432
1553
|
}
|
|
1433
1554
|
if (!profileName) {
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
if (seen.has(profile.name))
|
|
1437
|
-
continue;
|
|
1438
|
-
if ([...seen].some((s) => connectionKeyMatchesProfile(s, profile.name)))
|
|
1555
|
+
for (const profile of await listProfiles()) {
|
|
1556
|
+
if (seenProfiles.has(profile.name))
|
|
1439
1557
|
continue;
|
|
1440
1558
|
const reconciled = await this.reconcileFromDisk(profile.name);
|
|
1441
1559
|
if (reconciled)
|
|
@@ -1449,31 +1567,28 @@ export class BrowserService {
|
|
|
1449
1567
|
}
|
|
1450
1568
|
return statuses;
|
|
1451
1569
|
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Rebuild a profile's status from its runtime dirs when no live connection is
|
|
1572
|
+
* registered — a daemon that never connected this profile, or a browser
|
|
1573
|
+
* started by an earlier daemon.
|
|
1574
|
+
*
|
|
1575
|
+
* Takes a BARE profile name and walks every runtime key that belongs to it
|
|
1576
|
+
* ({@link listProfileCacheDirs}), including the legacy pre-composite dir named
|
|
1577
|
+
* exactly the profile.
|
|
1578
|
+
*/
|
|
1452
1579
|
async reconcileFromDisk(profileName) {
|
|
1453
|
-
// Runtime dirs live under the composite (`comet-local@endpoint-0`), not the
|
|
1454
|
-
// bare YAML name. Walk every matching cache dir so bare `status --profile`
|
|
1455
|
-
// sees the same tasks as unscoped `status`.
|
|
1456
1580
|
const dirs = listProfileCacheDirs(profileName);
|
|
1457
|
-
if (dirs.length === 0) {
|
|
1458
|
-
// Legacy: non-composite dir named exactly the bare profile.
|
|
1459
|
-
const info = getRunningChromeInfo(profileName);
|
|
1460
|
-
if (!info)
|
|
1461
|
-
return null;
|
|
1462
|
-
return this.profileStatusFromDisk(profileName, profileName, info.port, info.pid);
|
|
1463
|
-
}
|
|
1464
|
-
// Prefer a dir that still has a live browser; fall back to the first.
|
|
1465
1581
|
for (const dir of dirs) {
|
|
1466
|
-
const
|
|
1467
|
-
const info = getRunningChromeInfo(
|
|
1582
|
+
const key = asConnectionKey(path.basename(dir));
|
|
1583
|
+
const info = getRunningChromeInfo(key);
|
|
1468
1584
|
if (!info)
|
|
1469
1585
|
continue;
|
|
1470
|
-
return this.profileStatusFromDisk(profileName,
|
|
1586
|
+
return this.profileStatusFromDisk(profileName, key, info.port, info.pid);
|
|
1471
1587
|
}
|
|
1472
1588
|
return null;
|
|
1473
1589
|
}
|
|
1474
|
-
async profileStatusFromDisk(
|
|
1475
|
-
const
|
|
1476
|
-
const profile = await getProfile(bare);
|
|
1590
|
+
async profileStatusFromDisk(profileName, runtimeKey, port, pid) {
|
|
1591
|
+
const profile = await getProfile(profileName);
|
|
1477
1592
|
const tasks = this.loadTaskState(runtimeKey);
|
|
1478
1593
|
const taskStatuses = [];
|
|
1479
1594
|
for (const [, task] of tasks) {
|
|
@@ -1488,7 +1603,10 @@ export class BrowserService {
|
|
|
1488
1603
|
}
|
|
1489
1604
|
const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
|
|
1490
1605
|
return {
|
|
1491
|
-
name
|
|
1606
|
+
// Bare name for the user; the endpoint travels in its own field.
|
|
1607
|
+
name: profileName,
|
|
1608
|
+
endpoint: parseConnectionKey(runtimeKey).endpoint,
|
|
1609
|
+
key: runtimeKey,
|
|
1492
1610
|
running: true,
|
|
1493
1611
|
port,
|
|
1494
1612
|
pid,
|
|
@@ -1800,8 +1918,8 @@ export class BrowserService {
|
|
|
1800
1918
|
* effort: a remote CDP endpoint that doesn't expose the Browser domain must not
|
|
1801
1919
|
* fail the whole connect.
|
|
1802
1920
|
*/
|
|
1803
|
-
async applyDefaultDownloadBehavior(conn,
|
|
1804
|
-
const downloadPath = getProfileDownloadsDir(
|
|
1921
|
+
async applyDefaultDownloadBehavior(conn, key) {
|
|
1922
|
+
const downloadPath = getProfileDownloadsDir(key);
|
|
1805
1923
|
try {
|
|
1806
1924
|
await fs.promises.mkdir(downloadPath, { recursive: true });
|
|
1807
1925
|
await conn.cdp.send('Browser.setDownloadBehavior', {
|
|
@@ -1817,7 +1935,7 @@ export class BrowserService {
|
|
|
1817
1935
|
}
|
|
1818
1936
|
}
|
|
1819
1937
|
async setDownloadPath(taskId, downloadPath, tabHint) {
|
|
1820
|
-
const { conn, task,
|
|
1938
|
+
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
1821
1939
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1822
1940
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1823
1941
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -1826,7 +1944,7 @@ export class BrowserService {
|
|
|
1826
1944
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
1827
1945
|
// No explicit --path: fall back to the profile's downloads dir, the same
|
|
1828
1946
|
// destination already set browser-global at connect (applyDefaultDownloadBehavior).
|
|
1829
|
-
const resolvedPath = downloadPath ?? getProfileDownloadsDir(
|
|
1947
|
+
const resolvedPath = downloadPath ?? getProfileDownloadsDir(runtimeKey);
|
|
1830
1948
|
await fs.promises.mkdir(resolvedPath, { recursive: true });
|
|
1831
1949
|
await conn.cdp.send('Browser.setDownloadBehavior', {
|
|
1832
1950
|
behavior: 'allow',
|
|
@@ -1904,20 +2022,20 @@ export class BrowserService {
|
|
|
1904
2022
|
}
|
|
1905
2023
|
this.connections.clear();
|
|
1906
2024
|
}
|
|
1907
|
-
findAvailableFork(
|
|
2025
|
+
findAvailableFork(baseKey) {
|
|
1908
2026
|
for (const [name, conn] of this.connections) {
|
|
1909
|
-
if (conn.forkedFrom ===
|
|
2027
|
+
if (conn.forkedFrom === baseKey && conn.tasks.size === 0) {
|
|
1910
2028
|
return { name, conn };
|
|
1911
2029
|
}
|
|
1912
2030
|
}
|
|
1913
2031
|
return null;
|
|
1914
2032
|
}
|
|
1915
|
-
async forkElectronProfile(profile) {
|
|
2033
|
+
async forkElectronProfile(profile, baseKey) {
|
|
1916
2034
|
let forkNum = 2;
|
|
1917
|
-
while (this.connections.has(`${
|
|
2035
|
+
while (this.connections.has(asConnectionKey(`${baseKey}.${forkNum}`))) {
|
|
1918
2036
|
forkNum++;
|
|
1919
2037
|
}
|
|
1920
|
-
const forkName = `${
|
|
2038
|
+
const forkName = asConnectionKey(`${baseKey}.${forkNum}`);
|
|
1921
2039
|
const port = allocatePort();
|
|
1922
2040
|
const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
|
|
1923
2041
|
const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
|
|
@@ -1929,9 +2047,11 @@ export class BrowserService {
|
|
|
1929
2047
|
port,
|
|
1930
2048
|
pid,
|
|
1931
2049
|
electron: true,
|
|
2050
|
+
browserType: profile.browser,
|
|
1932
2051
|
targetFilter: profile.targetFilter,
|
|
1933
|
-
|
|
1934
|
-
|
|
2052
|
+
key: forkName,
|
|
2053
|
+
profile: profile.name,
|
|
2054
|
+
forkedFrom: baseKey,
|
|
1935
2055
|
tasks: new Map(),
|
|
1936
2056
|
sessionCache: new Map(),
|
|
1937
2057
|
};
|
|
@@ -1944,12 +2064,13 @@ export class BrowserService {
|
|
|
1944
2064
|
* already resolved the endpoint and built the `effectiveProfile` with
|
|
1945
2065
|
* the per-endpoint binary/targetFilter overrides applied; we just use it.
|
|
1946
2066
|
*
|
|
1947
|
-
* `
|
|
1948
|
-
* so per-endpoint pid/port files don't collide when the same app
|
|
1949
|
-
* locally and remotely at the same time.
|
|
2067
|
+
* `key` is the runtime key (`<profile>@<endpoint>`) and is what every on-disk
|
|
2068
|
+
* lookup uses, so per-endpoint pid/port files don't collide when the same app
|
|
2069
|
+
* runs locally and remotely at the same time. It is a SEPARATE argument
|
|
2070
|
+
* because `effectiveProfile.name` stays the bare, user-facing name (RUSH-2709).
|
|
1950
2071
|
*/
|
|
1951
|
-
async connectProfile(effectiveProfile, target) {
|
|
1952
|
-
const existingInfo = getRunningChromeInfo(
|
|
2072
|
+
async connectProfile(effectiveProfile, target, key) {
|
|
2073
|
+
const existingInfo = getRunningChromeInfo(key);
|
|
1953
2074
|
if (existingInfo) {
|
|
1954
2075
|
try {
|
|
1955
2076
|
const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
|
|
@@ -1957,12 +2078,13 @@ export class BrowserService {
|
|
|
1957
2078
|
const cdp = new CDPClient();
|
|
1958
2079
|
await cdp.connect(wsUrl);
|
|
1959
2080
|
await this.enableDomains(cdp);
|
|
1960
|
-
const tasks = this.loadTaskState(
|
|
2081
|
+
const tasks = this.loadTaskState(key);
|
|
1961
2082
|
return {
|
|
1962
2083
|
cdp,
|
|
1963
2084
|
port: existingInfo.port,
|
|
1964
2085
|
pid: existingInfo.pid,
|
|
1965
2086
|
electron: effectiveProfile.electron,
|
|
2087
|
+
browserType: effectiveProfile.browser,
|
|
1966
2088
|
targetFilter: effectiveProfile.targetFilter,
|
|
1967
2089
|
tasks,
|
|
1968
2090
|
sessionCache: new Map(),
|
|
@@ -1975,38 +2097,40 @@ export class BrowserService {
|
|
|
1975
2097
|
// or because the OS reused the pid for an unrelated process.
|
|
1976
2098
|
// Wipe the stale runtime files and fall through to a fresh
|
|
1977
2099
|
// connect against the profile's currently-configured endpoint.
|
|
1978
|
-
clearProfileRuntime(
|
|
2100
|
+
clearProfileRuntime(key);
|
|
1979
2101
|
}
|
|
1980
2102
|
}
|
|
1981
|
-
const conn = await this.connectEndpoint(effectiveProfile, target);
|
|
2103
|
+
const conn = await this.connectEndpoint(effectiveProfile, target, key);
|
|
1982
2104
|
if (!conn) {
|
|
1983
2105
|
throw new Error(`Could not connect to endpoint ${target} for profile "${effectiveProfile.name}"`);
|
|
1984
2106
|
}
|
|
1985
2107
|
return conn;
|
|
1986
2108
|
}
|
|
1987
|
-
async connectEndpoint(profile, endpoint) {
|
|
2109
|
+
async connectEndpoint(profile, endpoint, key) {
|
|
1988
2110
|
const url = new URL(endpoint);
|
|
1989
2111
|
if (url.protocol === 'cdp:') {
|
|
1990
|
-
const conn = await connectLocal(endpoint, profile);
|
|
2112
|
+
const conn = await connectLocal(endpoint, profile, key);
|
|
1991
2113
|
await this.enableDomains(conn.cdp);
|
|
1992
2114
|
return {
|
|
1993
2115
|
cdp: conn.cdp,
|
|
1994
2116
|
port: conn.port,
|
|
1995
2117
|
pid: conn.pid,
|
|
1996
2118
|
electron: profile.electron,
|
|
2119
|
+
browserType: profile.browser,
|
|
1997
2120
|
targetFilter: profile.targetFilter,
|
|
1998
|
-
tasks: conn.pid === 0 ? this.loadTaskState(
|
|
2121
|
+
tasks: conn.pid === 0 ? this.loadTaskState(key) : new Map(),
|
|
1999
2122
|
sessionCache: new Map(),
|
|
2000
2123
|
};
|
|
2001
2124
|
}
|
|
2002
2125
|
if (url.protocol === 'ssh:') {
|
|
2003
|
-
const conn = await connectSSH(endpoint, profile);
|
|
2126
|
+
const conn = await connectSSH(endpoint, profile, key);
|
|
2004
2127
|
await this.enableDomains(conn.cdp);
|
|
2005
2128
|
return {
|
|
2006
2129
|
cdp: conn.cdp,
|
|
2007
2130
|
port: conn.port,
|
|
2008
2131
|
pid: conn.pid,
|
|
2009
2132
|
electron: profile.electron,
|
|
2133
|
+
browserType: profile.browser,
|
|
2010
2134
|
targetFilter: profile.targetFilter,
|
|
2011
2135
|
tasks: new Map(),
|
|
2012
2136
|
sessionCache: new Map(),
|
|
@@ -2028,8 +2152,9 @@ export class BrowserService {
|
|
|
2028
2152
|
port: 0,
|
|
2029
2153
|
pid: 0,
|
|
2030
2154
|
electron: profile.electron,
|
|
2155
|
+
browserType: profile.browser,
|
|
2031
2156
|
targetFilter: profile.targetFilter,
|
|
2032
|
-
tasks: this.loadTaskState(
|
|
2157
|
+
tasks: this.loadTaskState(key),
|
|
2033
2158
|
sessionCache: new Map(),
|
|
2034
2159
|
};
|
|
2035
2160
|
}
|
|
@@ -2045,8 +2170,9 @@ export class BrowserService {
|
|
|
2045
2170
|
port,
|
|
2046
2171
|
pid: 0,
|
|
2047
2172
|
electron: profile.electron,
|
|
2173
|
+
browserType: profile.browser,
|
|
2048
2174
|
targetFilter: profile.targetFilter,
|
|
2049
|
-
tasks: this.loadTaskState(
|
|
2175
|
+
tasks: this.loadTaskState(key),
|
|
2050
2176
|
sessionCache: new Map(),
|
|
2051
2177
|
};
|
|
2052
2178
|
}
|
|
@@ -2055,6 +2181,19 @@ export class BrowserService {
|
|
|
2055
2181
|
async enableDomains(cdp) {
|
|
2056
2182
|
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
2057
2183
|
}
|
|
2184
|
+
/**
|
|
2185
|
+
* The single path that opens a new CDP page/window target. It exists so the
|
|
2186
|
+
* Arc guard lives in exactly one place: `Target.createTarget` crashes Arc
|
|
2187
|
+
* (verified, PR #2778), so an Arc connection throws a clear, actionable error
|
|
2188
|
+
* here instead of every call site re-checking. Every drivable Chromium-family
|
|
2189
|
+
* browser goes straight through.
|
|
2190
|
+
*/
|
|
2191
|
+
async createPageTarget(conn, params) {
|
|
2192
|
+
if (conn.browserType === 'arc') {
|
|
2193
|
+
throw arcNotDrivableError(conn.profile);
|
|
2194
|
+
}
|
|
2195
|
+
return (await conn.cdp.send('Target.createTarget', params));
|
|
2196
|
+
}
|
|
2058
2197
|
async getOrCreateWindow(conn) {
|
|
2059
2198
|
// Already have a window for this profile?
|
|
2060
2199
|
if (conn.windowId) {
|
|
@@ -2086,10 +2225,10 @@ export class BrowserService {
|
|
|
2086
2225
|
`Available page targets:\n${candidates || ' (none)'}`);
|
|
2087
2226
|
}
|
|
2088
2227
|
// First ever use - create window
|
|
2089
|
-
const result =
|
|
2228
|
+
const result = await this.createPageTarget(conn, {
|
|
2090
2229
|
url: 'about:blank',
|
|
2091
2230
|
newWindow: true,
|
|
2092
|
-
})
|
|
2231
|
+
});
|
|
2093
2232
|
conn.windowId = result.targetId;
|
|
2094
2233
|
return result.targetId;
|
|
2095
2234
|
}
|
|
@@ -2154,7 +2293,7 @@ export class BrowserService {
|
|
|
2154
2293
|
if (matches.length === 1) {
|
|
2155
2294
|
const m = matches[0];
|
|
2156
2295
|
await this.touchTask(m.conn, m.task);
|
|
2157
|
-
return { conn: m.conn, task: m.task,
|
|
2296
|
+
return { conn: m.conn, task: m.task, key: m.key, created: false };
|
|
2158
2297
|
}
|
|
2159
2298
|
if (matches.length > 1) {
|
|
2160
2299
|
const lines = matches.map((m) => {
|
|
@@ -2194,7 +2333,7 @@ export class BrowserService {
|
|
|
2194
2333
|
for (const task of conn.tasks.values()) {
|
|
2195
2334
|
if (hasIdentity) {
|
|
2196
2335
|
if (taskMatchesCaller(task, caller)) {
|
|
2197
|
-
out.push({ conn, task,
|
|
2336
|
+
out.push({ conn, task, key: conn.key ?? key });
|
|
2198
2337
|
}
|
|
2199
2338
|
}
|
|
2200
2339
|
else {
|
|
@@ -2202,7 +2341,7 @@ export class BrowserService {
|
|
|
2202
2341
|
// (a human-driven shell). If there is exactly one live task total,
|
|
2203
2342
|
// the single-task path still needs that listed — fall through below.
|
|
2204
2343
|
if (!task.sessionId && !task.launchId) {
|
|
2205
|
-
out.push({ conn, task,
|
|
2344
|
+
out.push({ conn, task, key: conn.key ?? key });
|
|
2206
2345
|
}
|
|
2207
2346
|
}
|
|
2208
2347
|
}
|
|
@@ -2217,7 +2356,7 @@ export class BrowserService {
|
|
|
2217
2356
|
const conn = this.connections.get(only.profile) ??
|
|
2218
2357
|
[...this.connections.entries()].find(([, c]) => c.tasks.has(only.task.name) || [...c.tasks.values()].includes(only.task))?.[1];
|
|
2219
2358
|
if (conn) {
|
|
2220
|
-
out.push({ conn, task: only.task,
|
|
2359
|
+
out.push({ conn, task: only.task, key: only.profile });
|
|
2221
2360
|
}
|
|
2222
2361
|
}
|
|
2223
2362
|
}
|
|
@@ -2268,33 +2407,34 @@ export class BrowserService {
|
|
|
2268
2407
|
// `recordBrowserSession` call in `start` uses for the same reason.
|
|
2269
2408
|
}
|
|
2270
2409
|
}
|
|
2271
|
-
async findTask(taskId,
|
|
2272
|
-
if (
|
|
2410
|
+
async findTask(taskId, profileRef) {
|
|
2411
|
+
if (profileRef) {
|
|
2273
2412
|
// Accept bare profile names against composite connection keys.
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2413
|
+
// One rule: an exact runtime key, else every key belonging to that bare
|
|
2414
|
+
// profile. Callers pass whichever they hold — the composite is theirs to
|
|
2415
|
+
// ignore (RUSH-2709).
|
|
2416
|
+
const keys = [...this.connections.keys()].filter((k) => k === profileRef || keyBelongsToProfile(k, profileRef));
|
|
2277
2417
|
for (const key of keys) {
|
|
2278
2418
|
const conn = this.connections.get(key);
|
|
2279
2419
|
const task = this.lookupTaskOnConn(conn, taskId);
|
|
2280
2420
|
if (task) {
|
|
2281
2421
|
await this.touchTask(conn, task);
|
|
2282
|
-
return { conn, task,
|
|
2422
|
+
return { conn, task, key: conn.key ?? key };
|
|
2283
2423
|
}
|
|
2284
2424
|
}
|
|
2285
2425
|
// RAM miss → rehydrate from disk for this profile.
|
|
2286
|
-
const rehydrated = await this.rehydrateTaskFromDisk(taskId,
|
|
2426
|
+
const rehydrated = await this.rehydrateTaskFromDisk(taskId, profileRef);
|
|
2287
2427
|
if (rehydrated) {
|
|
2288
2428
|
await this.touchTask(rehydrated.conn, rehydrated.task);
|
|
2289
2429
|
return rehydrated;
|
|
2290
2430
|
}
|
|
2291
|
-
throw new Error(actionable(`Task "${taskId}" not found on profile "${
|
|
2431
|
+
throw new Error(actionable(`Task "${taskId}" not found on profile "${profileRef}".`, `Next: agents browser status --profile ${parseConnectionKey(profileRef).profile}`));
|
|
2292
2432
|
}
|
|
2293
2433
|
for (const [key, conn] of this.connections) {
|
|
2294
2434
|
const task = this.lookupTaskOnConn(conn, taskId);
|
|
2295
2435
|
if (task) {
|
|
2296
2436
|
await this.touchTask(conn, task);
|
|
2297
|
-
return { conn, task,
|
|
2437
|
+
return { conn, task, key: conn.key ?? key };
|
|
2298
2438
|
}
|
|
2299
2439
|
}
|
|
2300
2440
|
// RAM miss after a daemon restart: tasks.json still has the task and
|
|
@@ -2318,11 +2458,8 @@ export class BrowserService {
|
|
|
2318
2458
|
* Returns null when nothing is speaking CDP so callers can fall through
|
|
2319
2459
|
* to disk reconcile.
|
|
2320
2460
|
*/
|
|
2321
|
-
async attachRunningProfile(
|
|
2322
|
-
const bare
|
|
2323
|
-
const endpointFromKey = dirName.includes('@')
|
|
2324
|
-
? dirName.slice(dirName.indexOf('@') + 1).split('.')[0]
|
|
2325
|
-
: undefined;
|
|
2461
|
+
async attachRunningProfile(key, diskTasks) {
|
|
2462
|
+
const { profile: bare, endpoint: endpointFromKey } = parseConnectionKey(key);
|
|
2326
2463
|
const profile = await getProfile(bare);
|
|
2327
2464
|
if (!profile)
|
|
2328
2465
|
return null;
|
|
@@ -2333,7 +2470,7 @@ export class BrowserService {
|
|
|
2333
2470
|
catch {
|
|
2334
2471
|
return null;
|
|
2335
2472
|
}
|
|
2336
|
-
const existingInfo = getRunningChromeInfo(
|
|
2473
|
+
const existingInfo = getRunningChromeInfo(key);
|
|
2337
2474
|
const parsed = parseEndpointUrl(resolved.target);
|
|
2338
2475
|
const port = existingInfo?.port ?? parsed?.port;
|
|
2339
2476
|
if (port === undefined)
|
|
@@ -2343,12 +2480,12 @@ export class BrowserService {
|
|
|
2343
2480
|
if (resolved.target.startsWith('ssh:'))
|
|
2344
2481
|
return null;
|
|
2345
2482
|
try {
|
|
2346
|
-
const { wsUrl, browser } = await discoverBrowserWsUrl(port, host,
|
|
2483
|
+
const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, bare);
|
|
2347
2484
|
verifyBrowserIdentity(browser, profile.browser, port, host);
|
|
2348
2485
|
const cdp = new CDPClient();
|
|
2349
2486
|
await cdp.connect(wsUrl);
|
|
2350
2487
|
await this.enableDomains(cdp);
|
|
2351
|
-
const tasks = this.loadTaskState(
|
|
2488
|
+
const tasks = this.loadTaskState(key);
|
|
2352
2489
|
for (const [k, t] of diskTasks) {
|
|
2353
2490
|
if (!tasks.has(k))
|
|
2354
2491
|
tasks.set(k, t);
|
|
@@ -2358,9 +2495,10 @@ export class BrowserService {
|
|
|
2358
2495
|
port,
|
|
2359
2496
|
pid: existingInfo?.pid ?? 0,
|
|
2360
2497
|
electron: profile.electron,
|
|
2498
|
+
browserType: profile.browser,
|
|
2361
2499
|
targetFilter: resolved.targetFilter ?? profile.targetFilter,
|
|
2362
|
-
|
|
2363
|
-
|
|
2500
|
+
key,
|
|
2501
|
+
profile: bare,
|
|
2364
2502
|
tasks,
|
|
2365
2503
|
sessionCache: new Map(),
|
|
2366
2504
|
};
|
|
@@ -2392,21 +2530,23 @@ export class BrowserService {
|
|
|
2392
2530
|
return;
|
|
2393
2531
|
}
|
|
2394
2532
|
for (const dirName of dirNames) {
|
|
2395
|
-
|
|
2533
|
+
// A runtime dir name IS a connection key — that is what wrote it.
|
|
2534
|
+
const key = asConnectionKey(dirName);
|
|
2535
|
+
if (this.connections.has(key))
|
|
2396
2536
|
continue;
|
|
2397
2537
|
// Skip non-runtime dirs (e.g. profiles/)
|
|
2398
2538
|
if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
|
|
2399
2539
|
continue;
|
|
2400
|
-
const tasks = this.loadTaskState(
|
|
2540
|
+
const tasks = this.loadTaskState(key);
|
|
2401
2541
|
if (tasks.size === 0)
|
|
2402
2542
|
continue;
|
|
2403
2543
|
// Soft attach only — never launch / never clear pid files.
|
|
2404
|
-
const conn = await this.attachRunningProfile(
|
|
2544
|
+
const conn = await this.attachRunningProfile(key, tasks);
|
|
2405
2545
|
if (!conn)
|
|
2406
2546
|
continue;
|
|
2407
|
-
this.connections.set(
|
|
2547
|
+
this.connections.set(key, conn);
|
|
2408
2548
|
try {
|
|
2409
|
-
await this.applyDefaultDownloadBehavior(conn,
|
|
2549
|
+
await this.applyDefaultDownloadBehavior(conn, key);
|
|
2410
2550
|
}
|
|
2411
2551
|
catch {
|
|
2412
2552
|
// Non-fatal for rehydrate.
|
|
@@ -2435,12 +2575,13 @@ export class BrowserService {
|
|
|
2435
2575
|
return null;
|
|
2436
2576
|
}
|
|
2437
2577
|
if (profileHint) {
|
|
2438
|
-
dirNames = dirNames.filter((d) => d === profileHint ||
|
|
2578
|
+
dirNames = dirNames.filter((d) => d === profileHint || keyBelongsToProfile(d, profileHint));
|
|
2439
2579
|
}
|
|
2440
2580
|
for (const dirName of dirNames) {
|
|
2441
2581
|
if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
|
|
2442
2582
|
continue;
|
|
2443
|
-
const
|
|
2583
|
+
const key = asConnectionKey(dirName);
|
|
2584
|
+
const tasks = this.loadTaskState(key);
|
|
2444
2585
|
let found;
|
|
2445
2586
|
for (const task of tasks.values()) {
|
|
2446
2587
|
if (task.id === taskId || task.name === taskId) {
|
|
@@ -2454,17 +2595,17 @@ export class BrowserService {
|
|
|
2454
2595
|
if (!found)
|
|
2455
2596
|
continue;
|
|
2456
2597
|
// Already connected under this key?
|
|
2457
|
-
let conn = this.connections.get(
|
|
2598
|
+
let conn = this.connections.get(key);
|
|
2458
2599
|
if (!conn) {
|
|
2459
|
-
const attached = await this.attachRunningProfile(
|
|
2600
|
+
const attached = await this.attachRunningProfile(key, tasks);
|
|
2460
2601
|
if (!attached) {
|
|
2461
2602
|
// CDP down — cannot rehydrate a live connection; caller will error.
|
|
2462
2603
|
continue;
|
|
2463
2604
|
}
|
|
2464
2605
|
conn = attached;
|
|
2465
|
-
this.connections.set(
|
|
2606
|
+
this.connections.set(key, conn);
|
|
2466
2607
|
try {
|
|
2467
|
-
await this.applyDefaultDownloadBehavior(conn,
|
|
2608
|
+
await this.applyDefaultDownloadBehavior(conn, key);
|
|
2468
2609
|
}
|
|
2469
2610
|
catch {
|
|
2470
2611
|
// Non-fatal.
|
|
@@ -2475,7 +2616,7 @@ export class BrowserService {
|
|
|
2475
2616
|
if (!this.lookupTaskOnConn(conn, task.name)) {
|
|
2476
2617
|
conn.tasks.set(task.name, task);
|
|
2477
2618
|
}
|
|
2478
|
-
return { conn, task,
|
|
2619
|
+
return { conn, task, key: conn.key ?? key };
|
|
2479
2620
|
}
|
|
2480
2621
|
return null;
|
|
2481
2622
|
}
|
|
@@ -2495,20 +2636,9 @@ export class BrowserService {
|
|
|
2495
2636
|
}
|
|
2496
2637
|
return tabs;
|
|
2497
2638
|
}
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
let resolvedKey = profileName;
|
|
2502
|
-
if (!conn) {
|
|
2503
|
-
for (const [key, c] of this.connections) {
|
|
2504
|
-
if (connectionKeyMatchesProfile(key, profileName) ||
|
|
2505
|
-
c.bareName === profileName) {
|
|
2506
|
-
conn = c;
|
|
2507
|
-
resolvedKey = key;
|
|
2508
|
-
break;
|
|
2509
|
-
}
|
|
2510
|
-
}
|
|
2511
|
-
}
|
|
2639
|
+
/** Live status for ONE registered connection, addressed by its runtime key. */
|
|
2640
|
+
async getProfileStatus(key) {
|
|
2641
|
+
const conn = this.connections.get(key);
|
|
2512
2642
|
if (!conn)
|
|
2513
2643
|
return null;
|
|
2514
2644
|
// Fetch all targets once for efficiency
|
|
@@ -2554,11 +2684,14 @@ export class BrowserService {
|
|
|
2554
2684
|
domains: domainSet.size > 0 ? Array.from(domainSet) : undefined,
|
|
2555
2685
|
});
|
|
2556
2686
|
}
|
|
2557
|
-
const
|
|
2687
|
+
const parsed = parseConnectionKey(key);
|
|
2688
|
+
const bare = conn.profile ?? parsed.profile;
|
|
2558
2689
|
const profile = await getProfile(bare);
|
|
2559
2690
|
const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
|
|
2560
2691
|
return {
|
|
2561
|
-
name:
|
|
2692
|
+
name: bare,
|
|
2693
|
+
endpoint: parsed.endpoint,
|
|
2694
|
+
key,
|
|
2562
2695
|
running: true,
|
|
2563
2696
|
port: conn.port,
|
|
2564
2697
|
pid: conn.pid,
|
|
@@ -2618,14 +2751,14 @@ export class BrowserService {
|
|
|
2618
2751
|
invalidateTargetCache(conn) {
|
|
2619
2752
|
conn.targetCache = undefined;
|
|
2620
2753
|
}
|
|
2621
|
-
async saveTaskState(
|
|
2622
|
-
const runtimeDir = getProfileRuntimeDir(
|
|
2754
|
+
async saveTaskState(key, tasks) {
|
|
2755
|
+
const runtimeDir = getProfileRuntimeDir(key);
|
|
2623
2756
|
await fs.promises.mkdir(runtimeDir, { recursive: true });
|
|
2624
2757
|
const state = Object.fromEntries(tasks);
|
|
2625
2758
|
await fs.promises.writeFile(path.join(runtimeDir, 'tasks.json'), JSON.stringify(state, null, 2));
|
|
2626
2759
|
}
|
|
2627
|
-
loadTaskState(
|
|
2628
|
-
const runtimeDir = getProfileRuntimeDir(
|
|
2760
|
+
loadTaskState(key) {
|
|
2761
|
+
const runtimeDir = getProfileRuntimeDir(key);
|
|
2629
2762
|
const tasksFile = path.join(runtimeDir, 'tasks.json');
|
|
2630
2763
|
if (!fs.existsSync(tasksFile)) {
|
|
2631
2764
|
return new Map();
|
|
@@ -2647,7 +2780,7 @@ export class BrowserService {
|
|
|
2647
2780
|
tasks.set(key, {
|
|
2648
2781
|
id: task.id,
|
|
2649
2782
|
name: task.name || key,
|
|
2650
|
-
profile: task.profile,
|
|
2783
|
+
profile: asConnectionKey(task.profile),
|
|
2651
2784
|
tabs,
|
|
2652
2785
|
currentTabId: tabIds.length > 0 ? tabIds[tabIds.length - 1] : undefined,
|
|
2653
2786
|
createdAt: task.createdAt,
|
|
@@ -2689,7 +2822,9 @@ export class BrowserService {
|
|
|
2689
2822
|
history.unshift({
|
|
2690
2823
|
id: task.id,
|
|
2691
2824
|
name: task.name,
|
|
2692
|
-
|
|
2825
|
+
// `task.profile` is the runtime key; history is a user-facing table, so
|
|
2826
|
+
// it carries the bare name (RUSH-2709).
|
|
2827
|
+
profile: parseConnectionKey(task.profile).profile,
|
|
2693
2828
|
createdAt: task.createdAt,
|
|
2694
2829
|
endedAt: Date.now(),
|
|
2695
2830
|
domains,
|