@phnx-labs/agents-cli 1.22.117 → 1.22.119
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 +301 -0
- package/dist/bootstrap.js +4 -11
- package/dist/commands/accounts.js +7 -4
- package/dist/commands/browser.d.ts +52 -70
- package/dist/commands/browser.js +192 -3562
- package/dist/commands/daemon.d.ts +39 -2
- package/dist/commands/daemon.js +72 -73
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -1
- package/dist/commands/projects.js +92 -7
- package/dist/commands/repo.js +13 -49
- package/dist/commands/route.js +5 -5
- package/dist/commands/routines.js +39 -12
- package/dist/commands/sessions-render.d.ts +1 -1
- package/dist/commands/sessions-render.js +0 -1
- package/dist/commands/sessions.test-fixture.d.ts +0 -1
- package/dist/commands/sessions.test-fixture.js +0 -37
- package/dist/commands/setup-browser.d.ts +17 -12
- package/dist/commands/setup-browser.js +52 -87
- package/dist/commands/setup-preferences.d.ts +0 -17
- package/dist/commands/setup-preferences.js +18 -49
- package/dist/commands/setup.js +9 -9
- package/dist/commands/ssh.js +10 -1
- package/dist/commands/sync.js +6 -44
- package/dist/commands/teams.js +0 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +34 -6
- package/dist/commands/view.d.ts +12 -1
- package/dist/commands/view.js +47 -2
- package/dist/index.js +107 -26
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-catalog.d.ts +38 -0
- package/dist/lib/account-catalog.js +110 -26
- package/dist/lib/account-provider-registry.js +1 -1
- package/dist/lib/account-registry.d.ts +1 -1
- package/dist/lib/account-registry.js +36 -2
- package/dist/lib/accounting/rotate.js +22 -8
- package/dist/lib/accounting/usage-ingest.d.ts +6 -1
- package/dist/lib/accounting/usage-ingest.js +115 -45
- package/dist/lib/accounting/usage-sync.d.ts +134 -31
- package/dist/lib/accounting/usage-sync.js +227 -66
- package/dist/lib/accounting/usage.d.ts +16 -3
- package/dist/lib/accounting/usage.js +26 -8
- package/dist/lib/accounts/add.d.ts +3 -1
- package/dist/lib/accounts/add.js +24 -13
- package/dist/lib/accounts/slots.d.ts +8 -0
- package/dist/lib/accounts/slots.js +17 -0
- package/dist/lib/add-dir.js +0 -1
- package/dist/lib/agent-cli-commands.js +0 -1
- package/dist/lib/agent-spec/agents.d.ts +6 -7
- package/dist/lib/agent-spec/agents.js +7 -60
- package/dist/lib/auth-health.d.ts +69 -1
- package/dist/lib/auth-health.js +133 -4
- package/dist/lib/browser/context.d.ts +82 -0
- package/dist/lib/browser/context.js +88 -0
- package/dist/lib/browser/paths.d.ts +25 -0
- package/dist/lib/browser/paths.js +51 -0
- package/dist/lib/browser/record.d.ts +38 -0
- package/dist/lib/browser/record.js +57 -0
- package/dist/lib/browser/sessions-list.js +1 -2
- package/dist/lib/browser-client.d.ts +158 -0
- package/dist/lib/browser-client.js +234 -0
- package/dist/lib/channels/owner-sink.d.ts +1 -18
- package/dist/lib/channels/owner-sink.js +29 -82
- package/dist/lib/channels/providers/rush.d.ts +7 -7
- package/dist/lib/channels/providers/rush.js +127 -57
- package/dist/lib/claude-account-token.d.ts +7 -1
- package/dist/lib/claude-account-token.js +26 -4
- package/dist/lib/claude-statusline.d.ts +1 -1
- package/dist/lib/claude-statusline.js +15 -3
- package/dist/lib/commands.js +1 -1
- package/dist/lib/crabbox/lease.js +1 -1
- package/dist/lib/crabbox/runtimes.js +1 -3
- package/dist/lib/daemon/account-state-daemon-service.js +1 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
- package/dist/lib/daemon/auth-sync-service.js +37 -43
- package/dist/lib/daemon/daemon.d.ts +25 -14
- package/dist/lib/daemon/daemon.js +92 -154
- package/dist/lib/daemon/runner.d.ts +2 -2
- package/dist/lib/daemon/runner.js +32 -9
- package/dist/lib/daemon/self-update-service.d.ts +35 -34
- package/dist/lib/daemon/self-update-service.js +40 -36
- package/dist/lib/daemon/service.d.ts +12 -8
- package/dist/lib/daemon/supervisor.d.ts +65 -54
- package/dist/lib/daemon/supervisor.js +136 -171
- package/dist/lib/daemon/testdata/process-view-start.js +5 -2
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
- package/dist/lib/daemon/usage-sync-service.js +46 -78
- package/dist/lib/daemon-health.d.ts +22 -5
- package/dist/lib/daemon-health.js +47 -3
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +0 -10
- package/dist/lib/daemon-ticks.js +4 -2
- package/dist/lib/devices/doctor-findings.js +8 -11
- package/dist/lib/devices/harness-inventory.js +14 -5
- package/dist/lib/exec-account-home.js +5 -0
- package/dist/lib/exec.js +10 -13
- package/dist/lib/feed/events.js +0 -1
- package/dist/lib/feed/tool-activity.js +5 -2
- package/dist/lib/fleet-shared-state.d.ts +45 -0
- package/dist/lib/fleet-shared-state.js +78 -9
- package/dist/lib/fs-atomic.js +25 -0
- package/dist/lib/github/pr-mergeable.d.ts +4 -2
- package/dist/lib/github/pr-mergeable.js +5 -3
- package/dist/lib/github/project-prs.d.ts +58 -3
- package/dist/lib/github/project-prs.js +205 -7
- package/dist/lib/harness/adapters/cursor.js +14 -4
- package/dist/lib/harness-auth-capabilities.d.ts +4 -3
- package/dist/lib/harness-auth-capabilities.js +1 -2
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hooks/cache.d.ts +7 -0
- package/dist/lib/hooks/cache.js +27 -2
- package/dist/lib/hooks/install.js +7 -1
- package/dist/lib/hosts/ready.d.ts +38 -8
- package/dist/lib/hosts/ready.js +62 -14
- package/dist/lib/installations/active-check.d.ts +27 -0
- package/dist/lib/installations/active-check.js +61 -0
- package/dist/lib/installations/migrate.js +6 -3
- package/dist/lib/installations/shims.d.ts +1 -1
- package/dist/lib/installations/shims.js +32 -12
- package/dist/lib/installations/store.d.ts +9 -0
- package/dist/lib/installations/store.js +24 -0
- package/dist/lib/installations/update.js +21 -15
- package/dist/lib/installations/versions.js +15 -3
- package/dist/lib/manifest.js +1 -1
- package/dist/lib/models.js +0 -2
- package/dist/lib/open-url.js +28 -54
- package/dist/lib/profiles.js +0 -2
- package/dist/lib/projects.d.ts +16 -0
- package/dist/lib/projects.js +31 -0
- package/dist/lib/refresh.js +1 -1
- package/dist/lib/routine-readiness.d.ts +35 -0
- package/dist/lib/routine-readiness.js +74 -16
- package/dist/lib/rules/compile.js +6 -0
- package/dist/lib/sandbox.js +0 -2
- package/dist/lib/secrets-policy.d.ts +89 -11
- package/dist/lib/secrets-policy.js +175 -28
- package/dist/lib/session/active.d.ts +17 -6
- package/dist/lib/session/active.js +105 -49
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +65 -18
- package/dist/lib/session/discover.js +1 -283
- package/dist/lib/session/mirror.d.ts +4 -3
- package/dist/lib/session/mirror.js +4 -3
- package/dist/lib/session/parse.d.ts +0 -2
- package/dist/lib/session/parse.js +1 -138
- package/dist/lib/session/pid-registry.d.ts +6 -3
- package/dist/lib/session/pid-registry.js +18 -8
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +7 -3
- package/dist/lib/session/throughput.d.ts +1 -3
- package/dist/lib/session/throughput.js +0 -20
- package/dist/lib/session/types.d.ts +2 -2
- package/dist/lib/session/types.js +2 -2
- package/dist/lib/sessions-client.d.ts +60 -1
- package/dist/lib/sessions-client.js +227 -4
- package/dist/lib/setup-tool-status.js +5 -10
- package/dist/lib/signin-badge.d.ts +3 -2
- package/dist/lib/signin-badge.js +6 -3
- package/dist/lib/smart-launch.js +4 -1
- package/dist/lib/staleness/detectors/commands.d.ts +1 -1
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
- package/dist/lib/staleness/detectors/rules.d.ts +1 -1
- package/dist/lib/staleness/detectors/skills.d.ts +1 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
- package/dist/lib/staleness/writers/commands.d.ts +1 -1
- package/dist/lib/staleness/writers/hooks.d.ts +1 -1
- package/dist/lib/staleness/writers/mcp.d.ts +1 -1
- package/dist/lib/staleness/writers/permissions.d.ts +1 -1
- package/dist/lib/staleness/writers/plugins.d.ts +1 -1
- package/dist/lib/staleness/writers/rules.d.ts +1 -1
- package/dist/lib/staleness/writers/skills.d.ts +1 -1
- package/dist/lib/staleness/writers/sources.js +5 -0
- package/dist/lib/staleness/writers/subagents.d.ts +1 -1
- package/dist/lib/staleness/writers/workflows.d.ts +1 -1
- package/dist/lib/state.d.ts +6 -4
- package/dist/lib/state.js +7 -17
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +1 -136
- package/dist/lib/teams/placement-probe.js +50 -15
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/types.d.ts +16 -1
- package/dist/lib/types.js +1 -1
- package/dist/lib/view-types.d.ts +32 -0
- package/dist/lib/watchdog/read.js +0 -1
- package/dist/lib/watchdog/rotate.js +1 -2
- package/dist/lib/watchdog/runner.js +2 -2
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdogTail.js +0 -31
- package/package.json +1 -1
- package/scripts/postinstall.js +8 -8
- package/dist/browser.d.ts +0 -2
- package/dist/browser.js +0 -17
- package/dist/commands/browser-picker.d.ts +0 -18
- package/dist/commands/browser-picker.js +0 -97
- package/dist/lib/browser/arc-discovery.d.ts +0 -52
- package/dist/lib/browser/arc-discovery.js +0 -197
- package/dist/lib/browser/arc-dom.d.ts +0 -14
- package/dist/lib/browser/arc-dom.js +0 -121
- package/dist/lib/browser/caller-identity.d.ts +0 -40
- package/dist/lib/browser/caller-identity.js +0 -175
- package/dist/lib/browser/cdp.d.ts +0 -51
- package/dist/lib/browser/cdp.js +0 -254
- package/dist/lib/browser/chrome.d.ts +0 -141
- package/dist/lib/browser/chrome.js +0 -704
- package/dist/lib/browser/chromium-discovery.d.ts +0 -28
- package/dist/lib/browser/chromium-discovery.js +0 -96
- package/dist/lib/browser/devices.d.ts +0 -34
- package/dist/lib/browser/devices.js +0 -61
- package/dist/lib/browser/domain-skills.d.ts +0 -71
- package/dist/lib/browser/domain-skills.js +0 -195
- package/dist/lib/browser/drivers/arc.d.ts +0 -40
- package/dist/lib/browser/drivers/arc.js +0 -276
- package/dist/lib/browser/drivers/firefox.d.ts +0 -99
- package/dist/lib/browser/drivers/firefox.js +0 -377
- package/dist/lib/browser/drivers/local.d.ts +0 -55
- package/dist/lib/browser/drivers/local.js +0 -266
- package/dist/lib/browser/drivers/ssh.d.ts +0 -120
- package/dist/lib/browser/drivers/ssh.js +0 -467
- package/dist/lib/browser/editor.d.ts +0 -3
- package/dist/lib/browser/editor.js +0 -50
- package/dist/lib/browser/ffmpeg.d.ts +0 -12
- package/dist/lib/browser/ffmpeg.js +0 -184
- package/dist/lib/browser/firefox-discovery.d.ts +0 -69
- package/dist/lib/browser/firefox-discovery.js +0 -162
- package/dist/lib/browser/har.d.ts +0 -85
- package/dist/lib/browser/har.js +0 -77
- package/dist/lib/browser/hygiene.d.ts +0 -97
- package/dist/lib/browser/hygiene.js +0 -153
- package/dist/lib/browser/index.d.ts +0 -5
- package/dist/lib/browser/index.js +0 -5
- package/dist/lib/browser/input.d.ts +0 -7
- package/dist/lib/browser/input.js +0 -92
- package/dist/lib/browser/ipc.d.ts +0 -204
- package/dist/lib/browser/ipc.js +0 -1453
- package/dist/lib/browser/login-detection.d.ts +0 -87
- package/dist/lib/browser/login-detection.js +0 -274
- package/dist/lib/browser/profiles.d.ts +0 -422
- package/dist/lib/browser/profiles.js +0 -1157
- package/dist/lib/browser/refs.d.ts +0 -89
- package/dist/lib/browser/refs.js +0 -191
- package/dist/lib/browser/registry.d.ts +0 -70
- package/dist/lib/browser/registry.js +0 -252
- package/dist/lib/browser/remote-control.d.ts +0 -59
- package/dist/lib/browser/remote-control.js +0 -85
- package/dist/lib/browser/resolve-target.d.ts +0 -66
- package/dist/lib/browser/resolve-target.js +0 -264
- package/dist/lib/browser/runtime-state.d.ts +0 -291
- package/dist/lib/browser/runtime-state.js +0 -584
- package/dist/lib/browser/secret-ref.d.ts +0 -10
- package/dist/lib/browser/secret-ref.js +0 -14
- package/dist/lib/browser/service.d.ts +0 -812
- package/dist/lib/browser/service.js +0 -4693
- package/dist/lib/browser/stream.d.ts +0 -17
- package/dist/lib/browser/stream.js +0 -72
- package/dist/lib/browser/task-index.d.ts +0 -78
- package/dist/lib/browser/task-index.js +0 -199
- package/dist/lib/browser/types.d.ts +0 -647
- package/dist/lib/browser/types.js +0 -61
- package/dist/lib/browser/upload.d.ts +0 -29
- package/dist/lib/browser/upload.js +0 -298
- package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
- package/dist/lib/daemon/browser-ipc-service.js +0 -46
- package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
- package/dist/lib/daemon/browser-task-reap-service.js +0 -26
- package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
- package/dist/lib/fleet-shared-repo-sync.js +0 -490
package/dist/commands/browser.js
CHANGED
|
@@ -1,3605 +1,235 @@
|
|
|
1
|
-
import { Option } from 'commander';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import * as fs from 'fs';
|
|
5
|
-
import * as path from 'path';
|
|
6
|
-
import { listProfiles, getProfile, createProfile, deleteProfile, getConfiguredDefaultProfileName, resolveProfileRef, resolveProfileRefForStart, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, formatProfilesTable, editProfile, renameProfile, assertRegistrableProfileName, isProfileLaunchableHere, isAttachOnlyProfile, resolveProfileDataDir, normalizeDataDir, persistDiscoveredProfile, } from '../lib/browser/profiles.js';
|
|
7
|
-
import { declaringDevices, migrateCentralBrowserProfiles, profileKind } from '../lib/browser/registry.js';
|
|
8
|
-
import { resolveActor } from '../lib/actor.js';
|
|
9
|
-
import { loginsForProfile, profilesLoggedInto, serviceForUrl, loginsWithAccountsForProfile, accountsForProfile, credKeysForService, AUTH_SIGNATURES, } from '../lib/browser/login-detection.js';
|
|
10
|
-
import { parseSecretRef } from '../lib/browser/secret-ref.js';
|
|
11
|
-
import { readAndResolveBundleEnv, bundleExists, readBundle } from '../lib/secrets-client.js';
|
|
12
|
-
import { findBrowserPath, getPortOccupant, getProcessUserDataDir, isLauncherScript, listInstalledBrowsers } from '../lib/browser/chrome.js';
|
|
13
|
-
import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, buildProfilePrunePlan, pruneProfiles, PRUNE_REASON_TEXT, identityLoopbackMismatch, } from '../lib/browser/runtime-state.js';
|
|
14
|
-
import { DEFAULT_VIEWPORT, parseWindowSize, parseWindowPosition } from '../lib/browser/devices.js';
|
|
15
|
-
import { runBrowserSessionsCommand } from './browser-sessions-picker.js';
|
|
16
|
-
import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
|
|
17
|
-
import { parseTargetFilter } from '../lib/browser/service.js';
|
|
18
|
-
import { BrowserServiceNotRunningError, formatBrowserServiceNotRunningError, sendIPCRequest as sendRawIPCRequest, stopBrowserService, } from '../lib/browser/ipc.js';
|
|
19
|
-
import { bindTask, getTaskBinding, honorScreenshotOutput, isTerminalBrowserVerb, REJECT_DEVICE_MESSAGE, resolveTaskRoute, unbindTask, unbindTasksForProfile, updateTaskBinding, } from '../lib/browser/task-index.js';
|
|
20
|
-
import { callerIdentityEnv, resolveCallerIdentity } from '../lib/browser/caller-identity.js';
|
|
21
|
-
import { isSelfHost } from '../lib/devices/self-host.js';
|
|
22
|
-
import { resolveHost } from '../lib/hosts/registry.js';
|
|
23
|
-
import { sshTargetFor } from '../lib/hosts/types.js';
|
|
24
|
-
import { withActorEnv } from '../lib/hosts/dispatch.js';
|
|
25
|
-
import { streamAgentsOnHost, passthroughSshOptions, } from '../lib/hosts/passthrough.js';
|
|
26
|
-
import { buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, stripRoutingFlags, } from '../lib/hosts/remote-cmd.js';
|
|
27
|
-
import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
|
|
28
|
-
import { sshExec, SSH_OPTS, sshStreamWithArgs } from '../lib/ssh-exec.js';
|
|
29
|
-
import { getBrowserRuntimeDir } from '../lib/state.js';
|
|
30
|
-
import { buildSshInvocation, writeAskpassShim } from '../lib/devices/connect.js';
|
|
31
|
-
import { resolveDeviceProfile } from '../lib/devices/resolve-profile.js';
|
|
32
|
-
import { resolveDeviceTarget } from '../lib/devices/resolve-target.js';
|
|
33
|
-
import { flagValue } from '../lib/hosts/routing-flag.js';
|
|
34
|
-
import { browserTaskPicker } from './browser-picker.js';
|
|
35
|
-
import { assertRemoteControlAllowed, isFleetRemoteInvocation } from '../lib/browser/remote-control.js';
|
|
36
|
-
import { getConfigValue, setConfigValue, unsetConfigValue } from '../lib/device-config.js';
|
|
37
|
-
import { isInteractiveTerminal } from './utils.js';
|
|
38
|
-
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
39
|
-
import { buildHar } from '../lib/browser/har.js';
|
|
40
|
-
import { getCliVersion } from '../lib/version.js';
|
|
41
|
-
import { runBrowserIPCStream } from '../lib/browser/stream.js';
|
|
42
|
-
import { machineId, normalizeHost } from '../lib/machine-id.js';
|
|
43
|
-
import { isArcRunning } from '../lib/browser/drivers/arc.js';
|
|
44
|
-
import { resolveBrowserTarget } from '../lib/browser/resolve-target.js';
|
|
45
|
-
/**
|
|
46
|
-
* Task name inferred from the local task→device index when `--task` was
|
|
47
|
-
* omitted and this session owns exactly one open task. Set by the page-verb
|
|
48
|
-
* preAction hook; consumed by {@link resolveTaskName}.
|
|
49
|
-
*/
|
|
50
|
-
let inferredTaskName;
|
|
51
1
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* 2. `$AGENTS_BROWSER_TASK` (optional shell default)
|
|
55
|
-
* 3. the local task-index match for this session (exactly one)
|
|
56
|
-
* 4. `undefined` — the daemon resolves from the caller's identity
|
|
2
|
+
* `agents browser` — the consumer surface over the standalone `browser` CLI
|
|
3
|
+
* (@phnx-labs/browser-cli, PHNX-4101).
|
|
57
4
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
5
|
+
* WHAT THIS FILE IS NOW. Every verb below forwards its arguments verbatim to the
|
|
6
|
+
* standalone engine and propagates its exit code. agents-cli contributes what the
|
|
7
|
+
* engine cannot know, carried on the fd-3 context (`lib/browser/context.ts`):
|
|
60
8
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (fromEnv)
|
|
75
|
-
return fromEnv;
|
|
76
|
-
return inferredTaskName;
|
|
77
|
-
}
|
|
78
|
-
// `-t` is taken by `--tab` on most commands, so `--task` is long-form only.
|
|
79
|
-
// The daemon resolves from caller identity when omitted.
|
|
80
|
-
const TASK_OPTION_FLAG = '--task <name>';
|
|
81
|
-
const TASK_OPTION_DESC = 'Task name (defaults to $AGENTS_BROWSER_TASK, else the caller\'s live task)';
|
|
82
|
-
const DEVICE_ON_PAGE_VERB_FLAG = '--device <name>';
|
|
83
|
-
const DEVICE_ON_PAGE_VERB_DESC = 'Not valid here — bind the device at `agents browser start --device`';
|
|
84
|
-
const TASK_ROUTED_COMMANDS = new Set([
|
|
85
|
-
'stream',
|
|
86
|
-
'done',
|
|
87
|
-
'stop',
|
|
88
|
-
'navigate',
|
|
89
|
-
'goto',
|
|
90
|
-
'tab',
|
|
91
|
-
'tabs',
|
|
92
|
-
'screenshot',
|
|
93
|
-
'pdf',
|
|
94
|
-
'evaluate',
|
|
95
|
-
'refs',
|
|
96
|
-
'click',
|
|
97
|
-
'type',
|
|
98
|
-
'press',
|
|
99
|
-
'hover',
|
|
100
|
-
'scroll',
|
|
101
|
-
'upload',
|
|
102
|
-
'set',
|
|
103
|
-
'console',
|
|
104
|
-
'errors',
|
|
105
|
-
'requests',
|
|
106
|
-
'logs',
|
|
107
|
-
'responsebody',
|
|
108
|
-
'wait',
|
|
109
|
-
'download',
|
|
110
|
-
'record',
|
|
111
|
-
'waitdownload',
|
|
112
|
-
]);
|
|
113
|
-
function commandPath(actionCommand) {
|
|
114
|
-
const parts = [];
|
|
115
|
-
let current = actionCommand;
|
|
116
|
-
while (current) {
|
|
117
|
-
const name = current.name();
|
|
118
|
-
if (name === 'agents' || name === 'browser')
|
|
119
|
-
break;
|
|
120
|
-
parts.unshift(name);
|
|
121
|
-
current = current.parent ?? null;
|
|
122
|
-
}
|
|
123
|
-
return parts;
|
|
124
|
-
}
|
|
125
|
-
function callerSessionId() {
|
|
126
|
-
return process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID;
|
|
127
|
-
}
|
|
128
|
-
function callerLaunchId() {
|
|
129
|
-
return process.env.AGENT_LAUNCH_ID;
|
|
130
|
-
}
|
|
131
|
-
function browserForwardedArgv() {
|
|
132
|
-
const raw = process.argv.slice(2);
|
|
133
|
-
const withCommand = raw[0] === 'browser' ? raw : ['browser', ...raw];
|
|
134
|
-
return stripRoutingFlags(withCommand, HOST_ROUTING_SPECS);
|
|
135
|
-
}
|
|
136
|
-
async function dispatchBrowserToDevice(device, forwardedArgs, mode) {
|
|
137
|
-
const host = await resolveHost(device);
|
|
138
|
-
if (!host) {
|
|
139
|
-
throw new Error(`Unknown device "${device}". Next: agents devices list`);
|
|
140
|
-
}
|
|
141
|
-
const target = sshTargetFor(host);
|
|
142
|
-
const remoteOs = resolveRemoteOsSync(host.name);
|
|
143
|
-
// Forward the caller's identity so the HUB resolves the same session/launch id
|
|
144
|
-
// the worker would stamp locally. Without it the forwarded verb lands blank on
|
|
145
|
-
// the hub and its no-identity task bucket collides with unrelated tasks — the
|
|
146
|
-
// surface-parity gap the run --device path already closes (caller-identity.ts).
|
|
147
|
-
const identityEnv = callerIdentityEnv(resolveCallerIdentity());
|
|
148
|
-
const env = withActorEnv({ ...identityEnv, AGENTS_FLEET_REMOTE: '1' });
|
|
149
|
-
const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, undefined, remoteOs, env);
|
|
150
|
-
if (mode === 'stream') {
|
|
151
|
-
const code = streamAgentsOnHost(host, forwardedArgs, {
|
|
152
|
-
interactive: !!process.stdout.isTTY && !process.argv.includes('--no-tty'),
|
|
153
|
-
extraEnv: { ...identityEnv, AGENTS_FLEET_REMOTE: '1' },
|
|
154
|
-
remoteOs,
|
|
155
|
-
target,
|
|
156
|
-
});
|
|
157
|
-
return { code, stdout: '', stderr: '' };
|
|
158
|
-
}
|
|
159
|
-
const sshOpts = passthroughSshOptions(host, false);
|
|
160
|
-
const result = sshExec(target, remoteCmd, {
|
|
161
|
-
multiplex: sshOpts.multiplex,
|
|
162
|
-
extraSshArgs: sshOpts.extraSshArgs,
|
|
163
|
-
});
|
|
164
|
-
return {
|
|
165
|
-
code: result.code ?? 1,
|
|
166
|
-
stdout: result.stdout,
|
|
167
|
-
stderr: result.stderr,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
export function remoteStartTaskName(stdout, explicit) {
|
|
171
|
-
if (explicit)
|
|
172
|
-
return explicit;
|
|
173
|
-
const trimmed = stdout.trim();
|
|
174
|
-
if (!trimmed)
|
|
175
|
-
return undefined;
|
|
176
|
-
try {
|
|
177
|
-
const decoded = JSON.parse(trimmed);
|
|
178
|
-
if (typeof decoded.task === 'string' && decoded.task.length > 0)
|
|
179
|
-
return decoded.task;
|
|
180
|
-
}
|
|
181
|
-
catch {
|
|
182
|
-
// Human output is one task name on the first non-empty line.
|
|
183
|
-
}
|
|
184
|
-
return trimmed.split('\n').map((line) => line.trim()).find(Boolean);
|
|
185
|
-
}
|
|
186
|
-
/**
|
|
187
|
-
* Fetch an absolute remote file into a private local path, bounded.
|
|
9
|
+
* 1. `--device <name>` resolved against the fleet — the device registry, ssh
|
|
10
|
+
* identity and platform — so the engine drives the right remote box without
|
|
11
|
+
* a fleet registry of its own (it matches the alias against `context.target`,
|
|
12
|
+
* then `~/.ssh/config`);
|
|
13
|
+
* 2. whether THIS machine consents to being driven by a peer
|
|
14
|
+
* (`browser.remote-control`); the engine enforces the resolved flag;
|
|
15
|
+
* 3. the acting actor and agent session, so an action lands in the right
|
|
16
|
+
* session history.
|
|
17
|
+
*
|
|
18
|
+
* The engine streams back the actions it performed on fd 4, and agents-cli — which
|
|
19
|
+
* owns `sessions.db` — records each into the durable `browser_sessions` row that
|
|
20
|
+
* `agents browser sessions` and `agents sessions --browser` read
|
|
21
|
+
* (`lib/browser/record.ts`).
|
|
188
22
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
23
|
+
* THE REMOTE PATH IS THE ENGINE's. `--device <name>` is resolved to an ssh target
|
|
24
|
+
* here and forwarded verbatim (the engine binds the device once at `start` and
|
|
25
|
+
* runs page verbs against the task's bound device). agents-cli opens no tunnel and
|
|
26
|
+
* publishes no endpoint: the engine owns the CDP/BiDi/Arc drivers, the IPC service,
|
|
27
|
+
* the chrome-data store and the profile declarations, keeping every on-disk path
|
|
28
|
+
* the in-repo subsystem used (integration contract §4).
|
|
194
29
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
30
|
+
* WHY VERB FLAGS ARE NOT REDECLARED HERE. Each passthrough verb takes everything
|
|
31
|
+
* as opaque operands via `allowUnknownOption`. Mirroring the engine's flags would
|
|
32
|
+
* create a second, silently drifting copy of its surface: a flag added upstream
|
|
33
|
+
* would be rejected here as unknown until someone noticed. The engine also owns
|
|
34
|
+
* per-verb `--help` for the same reason. What agents-cli keeps is the verb CATALOG
|
|
35
|
+
* — names, one-line descriptions, help groups — because that is what makes the
|
|
36
|
+
* surface discoverable from `agents browser --help`.
|
|
37
|
+
*
|
|
38
|
+
* `sessions` is the one verb that never reaches the engine: it reads agents-cli's
|
|
39
|
+
* own capture/session history (`browser-sessions-picker.ts`).
|
|
198
40
|
*/
|
|
41
|
+
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
42
|
+
import { buildBrowserContext } from '../lib/browser/context.js';
|
|
43
|
+
import { recordBrowserAction } from '../lib/browser/record.js';
|
|
44
|
+
import { isBrowserClientError, resolveBrowserBin, runBrowser, } from '../lib/browser-client.js';
|
|
45
|
+
import { runBrowserSessionsCommand } from './browser-sessions-picker.js';
|
|
46
|
+
// Help groups — mirror the standalone `browser --help` so the mental model
|
|
47
|
+
// carries over, and mirror `agents computer` where the two surfaces overlap.
|
|
48
|
+
const BROWSER_HELP_GROUPS = [
|
|
49
|
+
{ title: 'Session lifecycle', names: ['use', 'start', 'done', 'status', 'prune'] },
|
|
50
|
+
{ title: 'Fast action loop', names: ['stream'] },
|
|
51
|
+
{ title: 'Drive the page', names: ['navigate', 'tabs', 'screenshot', 'evaluate', 'click', 'type', 'press', 'wait'] },
|
|
52
|
+
{ title: 'Capture evidence', names: ['console', 'errors', 'requests', 'responsebody', 'record', 'pdf', 'logs'] },
|
|
53
|
+
{ title: 'History and discovery', names: ['sessions', 'history', 'refs'] },
|
|
54
|
+
{ title: 'Other', names: ['profiles', 'remote-control', 'stop', 'show', 'tab', 'ps', 'tasks', 'hover', 'scroll', 'upload', 'set', 'devices', 'download', 'waitdownload'] },
|
|
55
|
+
];
|
|
199
56
|
/**
|
|
200
|
-
* The
|
|
57
|
+
* The verb catalog. Descriptions are the consumer's (they appear in
|
|
58
|
+
* `agents browser --help`); flags are the engine's.
|
|
59
|
+
*
|
|
60
|
+
* This list is the contract with the engine: a verb the engine drops should fail
|
|
61
|
+
* loud here rather than silently vanish, and `browser.test.ts` pins the names so a
|
|
62
|
+
* drift shows up as a failing test. `sessions` is deliberately NOT in the list —
|
|
63
|
+
* it is agents-cli's own reader, registered separately.
|
|
201
64
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* file, splits it into lines, and re-encodes on output — so a screenshot arrives
|
|
205
|
-
* corrupted rather than merely reordered. The .NET path opens a `FileStream` and
|
|
206
|
-
* copies it into the raw stdout handle, bypassing PowerShell's object/text
|
|
207
|
-
* pipeline entirely, which is the only way binary survives.
|
|
65
|
+
* `start` is the ONE verb that resolves `--device`; the engine binds the device
|
|
66
|
+
* there and rejects `--device` on the page verbs itself.
|
|
208
67
|
*/
|
|
209
|
-
export
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
68
|
+
export const BROWSER_PASSTHROUGH_VERBS = [
|
|
69
|
+
{ name: 'use', description: 'Pick the profile `agents browser start` uses when no --profile is passed' },
|
|
70
|
+
{ name: 'start', description: 'Start a browser task — --profile/--url/--record/--title, and --device <name> to bind a remote box' },
|
|
71
|
+
{ name: 'done', description: 'Complete a task and close its tabs (resolves from caller identity when --task is omitted)' },
|
|
72
|
+
{ name: 'status', description: 'Show browser service state and running browser tasks' },
|
|
73
|
+
{ name: 'prune', description: 'Close tabs for abandoned tasks and mark them done — the reaper the daemon runs, on demand' },
|
|
74
|
+
{ name: 'stream', description: 'Keep one process + IPC socket open; read NDJSON requests from stdin, write NDJSON responses' },
|
|
75
|
+
{ name: 'navigate', description: 'Navigate the current tab to a URL (creates a task and tab when none exist)' },
|
|
76
|
+
{ name: 'tabs', description: 'List tabs open for the current task; --all shows every tab in the profile browser' },
|
|
77
|
+
{ name: 'screenshot', description: 'Take a screenshot — auto-saved per task; --output only to pick a specific path' },
|
|
78
|
+
{ name: 'evaluate', description: 'Evaluate JavaScript in the current tab' },
|
|
79
|
+
{ name: 'click', description: 'Click an element by ref, or raw coordinates with --at X,Y' },
|
|
80
|
+
{ name: 'type', description: 'Type text into an element by ref' },
|
|
81
|
+
{ name: 'press', description: 'Press a key (Enter, Tab, Escape, …)' },
|
|
82
|
+
{ name: 'wait', description: 'Wait for a condition' },
|
|
83
|
+
{ name: 'console', description: 'Read console logs from a tab' },
|
|
84
|
+
{ name: 'errors', description: 'Read page errors from a tab' },
|
|
85
|
+
{ name: 'requests', description: 'Read captured network requests; --format har emits a HAR 1.2 document' },
|
|
86
|
+
{ name: 'responsebody', description: 'Wait for and read a response body by URL pattern' },
|
|
87
|
+
{ name: 'record', description: 'Record a video of the page (record start / record stop)' },
|
|
88
|
+
{ name: 'pdf', description: 'Export the current tab as PDF via CDP — auto-saved under sessions/<task>/ when omitted' },
|
|
89
|
+
{ name: 'logs', description: 'Read merged rush-app + rush-cli logs for a task' },
|
|
90
|
+
{ name: 'history', description: 'Show recent browser task history' },
|
|
91
|
+
{ name: 'refs', description: 'Get DOM refs for interactive elements' },
|
|
92
|
+
{ name: 'profiles', description: 'Manage browser profiles (create / list / edit / rename / show / remove / use / …)' },
|
|
93
|
+
{ name: 'remote-control', description: 'Allow or deny other fleet machines driving THIS machine\'s browser (on/off; default off)' },
|
|
94
|
+
{ name: 'stop', description: 'Stop a task and close its tabs; --profile detaches the profile; --service stops the IPC service' },
|
|
95
|
+
{ name: 'show', description: 'Open a URL for a human to read (goes to browser.viewer; binds no task)' },
|
|
96
|
+
{ name: 'tab', description: 'Manage tabs (tab add / tab focus <id> / tab close [id])' },
|
|
97
|
+
{ name: 'ps', description: 'List every browser/electron/tunnel process agents has tracked — works without the daemon' },
|
|
98
|
+
{ name: 'tasks', description: 'List all browser tasks' },
|
|
99
|
+
{ name: 'hover', description: 'Hover over an element by ref' },
|
|
100
|
+
{ name: 'scroll', description: 'Scroll the page by a pixel amount (negatives scroll up/left)' },
|
|
101
|
+
{ name: 'upload', description: 'Upload file(s) — hidden inputs, drag-drop targets, and OS chooser interception' },
|
|
102
|
+
{ name: 'set', description: 'Set browser emulation options (set viewport / set device / set devices)' },
|
|
103
|
+
{ name: 'devices', description: 'List available device emulation presets' },
|
|
104
|
+
{ name: 'download', description: 'Set the download directory for a task' },
|
|
105
|
+
{ name: 'waitdownload', description: 'Wait for a download to complete' },
|
|
106
|
+
];
|
|
223
107
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* "however big the peer's file happens to be".
|
|
108
|
+
* Peek `--device <name>` (or `--device=name`) out of a raw argv without consuming
|
|
109
|
+
* it — the flag is forwarded to the engine verbatim, and agents-cli only reads it
|
|
110
|
+
* to resolve the fleet target for the fd-3 context. Pure, so it is unit-testable.
|
|
228
111
|
*/
|
|
229
|
-
export
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const wrote = fs.writeSync(handle, chunk, offset, chunk.length - offset);
|
|
237
|
-
if (wrote <= 0)
|
|
238
|
-
throw new Error('local write made no progress');
|
|
239
|
-
offset += wrote;
|
|
112
|
+
export function peekDevice(argv) {
|
|
113
|
+
for (let i = 0; i < argv.length; i++) {
|
|
114
|
+
const arg = argv[i];
|
|
115
|
+
if (arg === '--device')
|
|
116
|
+
return argv[i + 1];
|
|
117
|
+
if (arg.startsWith('--device='))
|
|
118
|
+
return arg.slice('--device='.length);
|
|
240
119
|
}
|
|
120
|
+
return undefined;
|
|
241
121
|
}
|
|
242
122
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* Auth is the CANONICAL device path, not a hand-rolled ssh: `buildSshInvocation`
|
|
246
|
-
* supplies the askpass shim for a password-auth box, `-i`/`IdentitiesOnly` for an
|
|
247
|
-
* explicit identity file, and the managed known-hosts pinning. Building the ssh
|
|
248
|
-
* args here instead meant a password-auth device could not be reached at all and
|
|
249
|
-
* an explicit `identityFile` was silently ignored.
|
|
123
|
+
* Forward one invocation to the engine and propagate its exit code.
|
|
250
124
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
* The local file is written 0600 inside a 0700 directory, because a capture can
|
|
256
|
-
* show anything that was on the peer's screen and a world-readable temp path
|
|
257
|
-
* would publish it to every local user.
|
|
125
|
+
* A missing standalone is the one failure agents-cli reports itself, because it
|
|
126
|
+
* is the one the engine cannot: it prints the install line and exits 1. There is
|
|
127
|
+
* no fallback engine to reach for — that is the point of the extraction.
|
|
258
128
|
*/
|
|
259
|
-
|
|
260
|
-
const posixAbsolute = path.posix.isAbsolute(remotePath);
|
|
261
|
-
const windowsAbsolute = /^[A-Za-z]:[\\/]/.test(remotePath) || /^\\\\/.test(remotePath);
|
|
262
|
-
if (!posixAbsolute && !windowsAbsolute) {
|
|
263
|
-
throw new Error(`Remote path must be absolute: got "${remotePath}".\n`
|
|
264
|
-
+ 'This machine cannot resolve the peer\'s working directory, so a relative path\n'
|
|
265
|
-
+ 'would name a different file there than you meant here.');
|
|
266
|
-
}
|
|
267
|
-
// `resolveDeviceTarget` is the SAME resolver `agents ssh` uses, so it yields the
|
|
268
|
-
// full device profile — platform, shell and auth — that `buildSshInvocation`
|
|
269
|
-
// needs. `resolveHost` returns a hosts-registry `Host`, which carries none of
|
|
270
|
-
// that: with it, a password-auth device could not be reached and an explicit
|
|
271
|
-
// identityFile was silently dropped.
|
|
272
|
-
const resolved = await resolveDeviceTarget(device);
|
|
273
|
-
if (!resolved)
|
|
274
|
-
throw new Error(`Unknown device "${device}". Next: agents devices list`);
|
|
275
|
-
const profile = resolveDeviceProfile(resolved);
|
|
276
|
-
const maxBytes = opts.maxBytes ?? REMOTE_VIEW_MAX_BYTES;
|
|
277
|
-
// A POSIX device cannot read a Windows path and vice versa; saying so here beats
|
|
278
|
-
// a confusing shell error from the peer.
|
|
279
|
-
if (profile.shell === 'powershell' && posixAbsolute && !windowsAbsolute) {
|
|
280
|
-
throw new Error(`"${remotePath}" is a POSIX path but ${device} runs PowerShell. Pass the Windows path (e.g. C:\\Users\\…).`);
|
|
281
|
-
}
|
|
282
|
-
if (profile.shell !== 'powershell' && windowsAbsolute) {
|
|
283
|
-
throw new Error(`"${remotePath}" is a Windows path but ${device} is a POSIX host. Pass the absolute POSIX path.`);
|
|
284
|
-
}
|
|
285
|
-
const resolvedLocal = path.resolve(localPath);
|
|
286
|
-
const dir = path.dirname(resolvedLocal);
|
|
287
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
288
|
-
fs.chmodSync(dir, 0o700);
|
|
289
|
-
const handle = fs.openSync(resolvedLocal, 'w', 0o600);
|
|
290
|
-
let written = 0;
|
|
291
|
-
let overflow = false;
|
|
292
|
-
const controller = new AbortController();
|
|
129
|
+
async function forwardToBrowser(opts) {
|
|
293
130
|
try {
|
|
294
|
-
|
|
295
|
-
// the same exact-token delivery `agents ssh --argv` exposes.
|
|
296
|
-
const { args, env } = buildSshInvocation(profile, remoteReadFileArgv(remotePath, profile.shell), writeAskpassShim(), {}, { argv: true });
|
|
297
|
-
const result = await sshStreamWithArgs({
|
|
298
|
-
args,
|
|
299
|
-
env,
|
|
300
|
-
timeoutMs: opts.timeoutMs ?? 120_000,
|
|
301
|
-
signal: controller.signal,
|
|
302
|
-
onStdout: (chunk) => {
|
|
303
|
-
if (overflow)
|
|
304
|
-
return;
|
|
305
|
-
if (written + chunk.length > maxBytes) {
|
|
306
|
-
overflow = true;
|
|
307
|
-
controller.abort();
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
writeFully(handle, chunk);
|
|
311
|
-
written += chunk.length;
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
|
-
if (overflow) {
|
|
315
|
-
throw new Error(`Refusing to pull ${device}:${remotePath}: larger than the ${maxBytes}-byte view budget.`);
|
|
316
|
-
}
|
|
317
|
-
if (result.timedOut)
|
|
318
|
-
throw new Error(`Timed out pulling ${device}:${remotePath}.`);
|
|
319
|
-
if (result.code !== 0) {
|
|
320
|
-
const detail = result.stderr.toString('utf8').trim();
|
|
321
|
-
throw new Error(`Failed to read ${device}:${remotePath}${detail ? `: ${detail}` : ` (ssh exited ${result.code})`}`);
|
|
322
|
-
}
|
|
323
|
-
if (written === 0)
|
|
324
|
-
throw new Error(`${device}:${remotePath} is empty or was not readable.`);
|
|
131
|
+
resolveBrowserBin();
|
|
325
132
|
}
|
|
326
|
-
catch (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (isBrowserClientError(err)) {
|
|
135
|
+
console.error(err.message);
|
|
136
|
+
return { exitCode: 1, stdout: '' };
|
|
330
137
|
}
|
|
331
|
-
|
|
332
|
-
throw error;
|
|
333
|
-
}
|
|
334
|
-
finally {
|
|
335
|
-
fs.closeSync(handle);
|
|
138
|
+
throw err;
|
|
336
139
|
}
|
|
140
|
+
const context = await buildBrowserContext({ device: opts.device });
|
|
141
|
+
return runBrowser({
|
|
142
|
+
argv: opts.argv,
|
|
143
|
+
context,
|
|
144
|
+
capture: opts.capture,
|
|
145
|
+
onEvent: (event) => recordBrowserAction(event, { device: opts.device }),
|
|
146
|
+
});
|
|
337
147
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const target = sshTargetFor(host);
|
|
344
|
-
fs.mkdirSync(path.dirname(path.resolve(localPath)), { recursive: true });
|
|
345
|
-
const sshOpts = passthroughSshOptions(host, false);
|
|
346
|
-
const copied = spawnSync('scp', [...sshOpts.extraSshArgs, ...SSH_OPTS, `${target}:${remotePath}`, path.resolve(localPath)], { encoding: 'utf8' });
|
|
347
|
-
if (copied.status !== 0) {
|
|
348
|
-
throw new Error(`Failed to copy screenshot from ${device}:${remotePath}: ${(copied.stderr || copied.stdout || '').trim()}`);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
function syncTaskIndex(request, response) {
|
|
352
|
-
if (!response.ok)
|
|
353
|
-
return;
|
|
354
|
-
if (request.action === 'stop' && request.profile && !request.task) {
|
|
355
|
-
unbindTasksForProfile(request.profile);
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
if (request.action === 'gc') {
|
|
359
|
-
if (!request.dryRun) {
|
|
360
|
-
for (const closed of response.reaped?.closed ?? []) {
|
|
361
|
-
unbindTask(closed.task);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
const name = response.task ?? request.task ?? request.taskName;
|
|
367
|
-
if (!name)
|
|
368
|
-
return;
|
|
369
|
-
if (request.action === 'done' || (request.action === 'stop' && request.task)) {
|
|
370
|
-
unbindTask(name);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
if (request.action === 'start') {
|
|
374
|
-
const existing = getTaskBinding(name);
|
|
375
|
-
if (!existing) {
|
|
376
|
-
bindTask(name, {
|
|
377
|
-
device: machineId(),
|
|
378
|
-
profile: request.profile,
|
|
379
|
-
url: request.url,
|
|
380
|
-
sessionId: request.sessionId ?? callerSessionId(),
|
|
381
|
-
launchId: request.launchId ?? callerLaunchId(),
|
|
382
|
-
createdAt: Date.now(),
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
else if (request.url) {
|
|
386
|
-
updateTaskBinding(name, { url: request.url });
|
|
387
|
-
}
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
if (request.url) {
|
|
391
|
-
const existing = getTaskBinding(name);
|
|
392
|
-
if (existing) {
|
|
393
|
-
updateTaskBinding(name, { url: request.url });
|
|
394
|
-
}
|
|
395
|
-
else {
|
|
396
|
-
bindTask(name, {
|
|
397
|
-
device: machineId(),
|
|
398
|
-
profile: request.profile,
|
|
399
|
-
url: request.url,
|
|
400
|
-
sessionId: request.sessionId ?? callerSessionId(),
|
|
401
|
-
launchId: request.launchId ?? callerLaunchId(),
|
|
402
|
-
createdAt: Date.now(),
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
if (!getTaskBinding(name)) {
|
|
408
|
-
bindTask(name, {
|
|
409
|
-
device: machineId(),
|
|
410
|
-
profile: request.profile,
|
|
411
|
-
sessionId: request.sessionId ?? callerSessionId(),
|
|
412
|
-
launchId: request.launchId ?? callerLaunchId(),
|
|
413
|
-
createdAt: Date.now(),
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
async function sendIPCRequest(request, opts) {
|
|
418
|
-
const response = await sendRawIPCRequest(request, opts);
|
|
419
|
-
syncTaskIndex(request, response);
|
|
420
|
-
return response;
|
|
421
|
-
}
|
|
422
|
-
function assertDeviceDeclaresProfile(device, profileName) {
|
|
423
|
-
const declared = declaringDevices(profileName);
|
|
424
|
-
if (declared.includes(device))
|
|
425
|
-
return;
|
|
426
|
-
const where = declared.length > 0
|
|
427
|
-
? `Declared on: ${declared.join(', ')}.`
|
|
428
|
-
: 'No device declares that profile.';
|
|
429
|
-
throw new Error(`Device "${device}" does not declare browser profile "${profileName}". ${where}\n` +
|
|
430
|
-
`Next: run \`agents browser profiles add ${profileName} --browser <b>\` on ${device}.`);
|
|
148
|
+
/** Forward, then exit with the engine's status so shells and agents see the truth. */
|
|
149
|
+
async function forwardAndExit(opts) {
|
|
150
|
+
const { exitCode } = await forwardToBrowser(opts);
|
|
151
|
+
if (exitCode !== 0)
|
|
152
|
+
process.exit(exitCode);
|
|
431
153
|
}
|
|
432
154
|
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
155
|
+
* Register every plain verb as an opaque forwarder.
|
|
156
|
+
*
|
|
157
|
+
* `allowUnknownOption` is what makes this thin: commander stops trying to parse
|
|
158
|
+
* flags it does not own and hands them through in `cmd.args`, so the engine's flag
|
|
159
|
+
* surface (and its `tab`/`set`/`record`/`profiles` subverbs) can grow without a
|
|
160
|
+
* matching edit here. `start` resolves `--device` from the raw args for the fd-3
|
|
161
|
+
* target; every other verb forwards it (and lets the engine reject a stray one).
|
|
440
162
|
*/
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
163
|
+
function registerPassthroughVerbs(program) {
|
|
164
|
+
for (const verb of BROWSER_PASSTHROUGH_VERBS) {
|
|
165
|
+
program
|
|
166
|
+
.command(verb.name)
|
|
167
|
+
.description(verb.description)
|
|
168
|
+
.allowUnknownOption(true)
|
|
169
|
+
.allowExcessArguments(true)
|
|
170
|
+
.helpOption(false)
|
|
171
|
+
.action(async (_opts, cmd) => {
|
|
172
|
+
const argv = [verb.name, ...cmd.args];
|
|
173
|
+
const device = verb.name === 'start' ? peekDevice(cmd.args) : undefined;
|
|
174
|
+
await forwardAndExit({ argv, device });
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// sessions — task-first history over the on-disk captures + `browser_sessions`
|
|
179
|
+
// rows (RUSH-2407), the browser counterpart of `agents computer sessions`. It
|
|
180
|
+
// reads agents-cli's own store, so it never reaches the engine. `agents sessions
|
|
181
|
+
// --browser` (sessions.ts) routes to the same runBrowserSessionsCommand.
|
|
182
|
+
function registerSessionsCommand(program) {
|
|
183
|
+
program
|
|
184
|
+
.command('sessions')
|
|
185
|
+
.description('Browse a profile\'s captured screenshots, PDFs, recordings, and downloads, grouped by task')
|
|
186
|
+
.option('--profile <name>', 'Only this profile (default: all profiles with captures)')
|
|
187
|
+
.option('--open [selector]', "Open a capture in the OS default app: 'latest' or a filename")
|
|
188
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
189
|
+
.option('--no-interactive', 'Print the flat listing instead of opening the interactive task browser')
|
|
190
|
+
.action(async (opts) => {
|
|
191
|
+
await runBrowserSessionsCommand({ profile: opts.profile, open: opts.open, json: opts.json, interactive: opts.interactive });
|
|
192
|
+
});
|
|
448
193
|
}
|
|
449
|
-
// Help groups — surfaces the actual mental model an agent follows
|
|
450
|
-
// ("open a session / drive the page / capture evidence / rare extras")
|
|
451
|
-
// instead of an alphabetical dump. Everything not listed falls into a
|
|
452
|
-
// trailing "Other commands" section automatically.
|
|
453
|
-
const BROWSER_HELP_GROUPS = [
|
|
454
|
-
{ title: 'Session lifecycle', names: ['use', 'start', 'done', 'status', 'prune'] },
|
|
455
|
-
{ title: 'Fast action loop', names: ['stream'] },
|
|
456
|
-
{
|
|
457
|
-
title: 'Drive the page',
|
|
458
|
-
names: ['navigate', 'tabs', 'screenshot', 'evaluate', 'click', 'type', 'press', 'wait'],
|
|
459
|
-
},
|
|
460
|
-
{
|
|
461
|
-
title: 'Capture evidence',
|
|
462
|
-
names: ['console', 'errors', 'requests', 'responsebody', 'record', 'pdf', 'logs'],
|
|
463
|
-
},
|
|
464
|
-
{ title: 'History and discovery', names: ['sessions', 'history', 'refs'] },
|
|
465
|
-
];
|
|
466
194
|
export function registerBrowserCommand(program) {
|
|
467
195
|
const browser = program
|
|
468
196
|
.command('browser')
|
|
469
|
-
.description('
|
|
197
|
+
.description('Drive a real browser (Chrome/Brave/Edge/Firefox/Arc) over CDP/BiDi — navigate, screenshot, click, capture; --device to drive a remote box');
|
|
198
|
+
registerPassthroughVerbs(browser);
|
|
199
|
+
registerSessionsCommand(browser);
|
|
200
|
+
registerCommandGroups(browser, BROWSER_HELP_GROUPS);
|
|
470
201
|
setHelpSections(browser, {
|
|
471
202
|
examples: `
|
|
472
|
-
#
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
agents browser profiles create work --browser chrome --endpoint http://localhost:9222
|
|
477
|
-
|
|
478
|
-
# Start a session on this machine's default browser (set it once with: agents browser use)
|
|
479
|
-
agents browser start
|
|
203
|
+
# One-time: install the engine, then pick a profile
|
|
204
|
+
npm i -g @phnx-labs/browser-cli
|
|
205
|
+
agents browser profiles create work --browser chromium
|
|
206
|
+
agents browser use work
|
|
480
207
|
|
|
481
|
-
#
|
|
208
|
+
# Start a task, drive it, capture, close
|
|
482
209
|
agents browser start --profile work
|
|
483
|
-
|
|
484
|
-
# Drive the page (no start / --task needed — identity resolves the task)
|
|
485
210
|
agents browser navigate https://example.com
|
|
486
|
-
agents browser screenshot
|
|
487
|
-
|
|
488
|
-
# Every tab open in the profile browser, yours next to the task's (OWNER column)
|
|
489
|
-
agents browser tabs --all
|
|
490
|
-
|
|
491
|
-
# Keep one process and browser-service socket warm for repeated actions
|
|
492
|
-
agents browser stream --task "$AGENTS_BROWSER_TASK"
|
|
493
|
-
|
|
494
|
-
# Bind a device at start; later verbs resolve it from the task
|
|
495
|
-
agents browser start --task post --device zion --url https://x.com/
|
|
496
|
-
agents browser type --task post --ref @e3 "hello"
|
|
497
|
-
agents browser screenshot --task post
|
|
498
|
-
|
|
499
|
-
# Browse a task-heavy profile's captures: one row per task, not per file
|
|
500
|
-
agents browser sessions
|
|
501
|
-
|
|
502
|
-
# Allow / deny other fleet machines driving THIS machine's browser
|
|
503
|
-
agents browser remote-control on
|
|
504
|
-
|
|
505
|
-
# End the session when done
|
|
211
|
+
agents browser screenshot -o /tmp/shot.png
|
|
506
212
|
agents browser done
|
|
507
213
|
|
|
508
|
-
#
|
|
509
|
-
agents browser
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
# stop only the browser service, then start it clean
|
|
513
|
-
agents browser stop --service
|
|
514
|
-
agents browser start
|
|
515
|
-
`,
|
|
516
|
-
notes: `
|
|
517
|
-
Most agent workflows should use the 'browser' skill instead of raw subcommands.
|
|
518
|
-
The skill wraps profile selection, snapshotting, and tunneling.
|
|
519
|
-
|
|
520
|
-
Browser support: Chromium-family over the Chrome DevTools Protocol (Chrome,
|
|
521
|
-
Comet, Chromium, Brave, Edge), Arc natively (one profile per Space), and
|
|
522
|
-
Firefox over WebDriver BiDi (one profile per profiles.ini entry; Firefox 129+
|
|
523
|
-
dropped CDP). Safari is not supported. On Windows, Edge is the default
|
|
524
|
-
because it's preinstalled. On macOS and Linux, Chrome is preferred when
|
|
525
|
-
installed; otherwise the first Chromium-family binary on disk wins.
|
|
526
|
-
`,
|
|
527
|
-
});
|
|
528
|
-
registerBrowserUseCommand(browser);
|
|
529
|
-
registerProfilesCommands(browser);
|
|
530
|
-
registerTaskCommands(browser);
|
|
531
|
-
registerCommandGroups(browser, BROWSER_HELP_GROUPS);
|
|
532
|
-
}
|
|
533
|
-
export function registerBrowserSubcommands(program) {
|
|
534
|
-
registerBrowserUseCommand(program);
|
|
535
|
-
registerProfilesCommands(program);
|
|
536
|
-
registerTaskCommands(program);
|
|
537
|
-
registerCommandGroups(program, BROWSER_HELP_GROUPS);
|
|
538
|
-
}
|
|
539
|
-
export function buildBrowserUseChoices(profiles, installed, current) {
|
|
540
|
-
const choices = profiles.map((profile) => ({
|
|
541
|
-
name: `${profile.name}${profile.name === current ? ' (current)' : ''}`,
|
|
542
|
-
value: { name: profile.name },
|
|
543
|
-
}));
|
|
544
|
-
const known = new Set(profiles.map((profile) => profile.name));
|
|
545
|
-
for (const browser of installed) {
|
|
546
|
-
const name = `${browser.browserType}-local`;
|
|
547
|
-
if (!known.has(name)) {
|
|
548
|
-
choices.push({ name: `${name} (installed ${browser.browserType})`, value: { name, installed: browser } });
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
return choices;
|
|
552
|
-
}
|
|
553
|
-
export async function runBrowserUse(name, opts, interactive = isInteractiveTerminal()) {
|
|
554
|
-
if (opts.unset || name === 'auto') {
|
|
555
|
-
unsetConfigValue('browser.profile');
|
|
556
|
-
console.log('Default browser profile cleared. Pick one with `agents browser use <name>` (or `agents setup`); until then `agents browser start` has no default to launch here.');
|
|
557
|
-
return true;
|
|
558
|
-
}
|
|
559
|
-
let selectedName = name;
|
|
560
|
-
if (!selectedName) {
|
|
561
|
-
const current = getConfiguredDefaultProfileName();
|
|
562
|
-
if (!interactive) {
|
|
563
|
-
console.log(current
|
|
564
|
-
? `Default browser profile (this machine): ${current}`
|
|
565
|
-
: 'Default browser profile (this machine): none — pick one with `agents browser use <name>`');
|
|
566
|
-
const hub = getConfigValue('browser.device').value;
|
|
567
|
-
if (hub) {
|
|
568
|
-
console.log(isSelfHost(hub)
|
|
569
|
-
? `Browser hub (browser.device): ${hub} — this machine, so drives locally`
|
|
570
|
-
: `Browser hub (browser.device): ${hub} — bare \`agents browser start\` drives ${hub}'s browser`);
|
|
571
|
-
}
|
|
572
|
-
console.log('Usage: agents browser use <name> (or --unset)');
|
|
573
|
-
console.log('Fleet hub: agents config set browser.device <device> (drive that box\'s browser from every machine)');
|
|
574
|
-
return true;
|
|
575
|
-
}
|
|
576
|
-
const choices = buildBrowserUseChoices(await listProfiles(), listInstalledBrowsers(), current);
|
|
577
|
-
if (choices.length === 0) {
|
|
578
|
-
console.error('No configured profiles or supported installed browsers found.');
|
|
579
|
-
console.error('Create one with: agents browser profiles create <name> --browser chrome');
|
|
580
|
-
return false;
|
|
581
|
-
}
|
|
582
|
-
const { select } = await import('@inquirer/prompts');
|
|
583
|
-
const selected = await select({ message: 'Select default browser profile:', choices });
|
|
584
|
-
selectedName = selected.name;
|
|
585
|
-
if (selected.installed) {
|
|
586
|
-
const freePort = await findFreeProfilePort();
|
|
587
|
-
await createProfile({
|
|
588
|
-
name: selected.name,
|
|
589
|
-
description: `Seeded ${selected.installed.browserType} profile`,
|
|
590
|
-
browser: selected.installed.browserType,
|
|
591
|
-
binary: selected.installed.binary,
|
|
592
|
-
endpoints: [`cdp://127.0.0.1:${freePort}`],
|
|
593
|
-
viewport: { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height },
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
const target = await getProfile(selectedName);
|
|
598
|
-
if (!target) {
|
|
599
|
-
console.error(`Profile "${selectedName}" not found.`);
|
|
600
|
-
const all = await listProfiles();
|
|
601
|
-
if (all.length > 0)
|
|
602
|
-
console.error(`Available profiles: ${all.map((profile) => profile.name).join(', ')}`);
|
|
603
|
-
return false;
|
|
604
|
-
}
|
|
605
|
-
await persistDiscoveredProfile(selectedName);
|
|
606
|
-
setConfigValue('browser.profile', selectedName);
|
|
607
|
-
const endpoint = Object.values(getEndpointPresets(target))[0]?.target ?? '';
|
|
608
|
-
console.log(`Default browser profile (this machine) is now "${selectedName}" (${target.browser}${endpoint ? `, ${endpoint}` : ''}).`);
|
|
609
|
-
console.log('Bare `agents browser start` and `--profile default` will use it.');
|
|
610
|
-
return true;
|
|
611
|
-
}
|
|
612
|
-
function configureBrowserUseCommand(command, deprecated = false) {
|
|
613
|
-
command
|
|
614
|
-
.description('Pick the profile `agents browser start` uses when no --profile is passed. No name opens a picker on a TTY or prints the current default headlessly.')
|
|
615
|
-
.option('--unset', 'Clear the configured default (leaves this machine with no default until you pick one)')
|
|
616
|
-
.action(async (name, opts) => {
|
|
617
|
-
if (deprecated) {
|
|
618
|
-
console.warn(chalk.yellow('Deprecation: `agents browser profiles set-default` is replaced by `agents browser use`.'));
|
|
619
|
-
}
|
|
620
|
-
if (!await runBrowserUse(name, opts))
|
|
621
|
-
process.exitCode = 1;
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
function registerBrowserUseCommand(browser) {
|
|
625
|
-
configureBrowserUseCommand(browser.command('use [name]'));
|
|
626
|
-
}
|
|
627
|
-
/**
|
|
628
|
-
* Whether a profile's secrets bundle holds login creds for `service`
|
|
629
|
-
* (`<PREFIX>_USERNAME` + `<PREFIX>_PASSWORD`). Reads bundle METADATA only (key
|
|
630
|
-
* names, never values) so it doesn't decrypt. Returns the bundle name when both
|
|
631
|
-
* keys exist, '-' when not, or '(unknown)' if the metadata read would prompt or
|
|
632
|
-
* fails — the view must never block on Touch ID.
|
|
633
|
-
*/
|
|
634
|
-
async function credAvailability(bundleName, service) {
|
|
635
|
-
if (!bundleName)
|
|
636
|
-
return '-';
|
|
637
|
-
const keys = credKeysForService(service);
|
|
638
|
-
if (!keys)
|
|
639
|
-
return '-';
|
|
640
|
-
try {
|
|
641
|
-
if (!(await bundleExists(bundleName)))
|
|
642
|
-
return '-';
|
|
643
|
-
const present = new Set(Object.keys((await readBundle(bundleName)).vars));
|
|
644
|
-
return present.has(keys.user) && present.has(keys.pass) ? bundleName : '-';
|
|
645
|
-
}
|
|
646
|
-
catch {
|
|
647
|
-
return '(unknown)';
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
function registerProfilesCommands(browser) {
|
|
651
|
-
const profiles = browser
|
|
652
|
-
.command('profiles')
|
|
653
|
-
.description('Manage browser profiles');
|
|
654
|
-
profiles
|
|
655
|
-
.command('list')
|
|
656
|
-
.alias('ls')
|
|
657
|
-
.description('List all browser profiles and the devices declaring each one (WHERE)')
|
|
658
|
-
.option('--json', 'Output machine-readable JSON (includes devices + kind)')
|
|
659
|
-
.action(async (opts) => {
|
|
660
|
-
const allProfiles = await listProfiles();
|
|
661
|
-
const configuredDefault = getConfiguredDefaultProfileName();
|
|
662
|
-
if (opts.json) {
|
|
663
|
-
console.log(JSON.stringify(allProfiles.map((profile) => ({
|
|
664
|
-
...profile,
|
|
665
|
-
devices: profile.devices,
|
|
666
|
-
kind: profile.arc || profile.firefox ? 'identity' : profileKind(profile.name),
|
|
667
|
-
isConfiguredDefault: profile.name === configuredDefault,
|
|
668
|
-
})), null, 2));
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
if (allProfiles.length === 0) {
|
|
672
|
-
console.log('No browser profiles configured.');
|
|
673
|
-
console.log('Create one with: agents browser profiles create <name> --browser chrome');
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
// Rendering lives in lib/browser/profiles.ts so the column widths and the
|
|
677
|
-
// default-marking rules are unit-tested rather than eyeballed (RUSH-2710).
|
|
678
|
-
for (const line of formatProfilesTable(allProfiles, configuredDefault))
|
|
679
|
-
console.log(line);
|
|
680
|
-
});
|
|
681
|
-
profiles
|
|
682
|
-
.command('seed')
|
|
683
|
-
.description('Create a machine-local profile for each installed browser (named <browser>-local), so you can pick or use one instead of hand-crafting each. Idempotent — existing profiles are left untouched.')
|
|
684
|
-
.action(async () => {
|
|
685
|
-
const installed = listInstalledBrowsers();
|
|
686
|
-
if (installed.length === 0) {
|
|
687
|
-
console.error('No supported browser found to seed a profile for.');
|
|
688
|
-
process.exit(1);
|
|
689
|
-
}
|
|
690
|
-
for (const { browserType, binary } of installed) {
|
|
691
|
-
const name = `${browserType}-local`;
|
|
692
|
-
if (await getProfile(name)) {
|
|
693
|
-
console.log(`= ${name} (already exists)`);
|
|
694
|
-
continue;
|
|
695
|
-
}
|
|
696
|
-
const freePort = await findFreeProfilePort();
|
|
697
|
-
await createProfile({
|
|
698
|
-
name,
|
|
699
|
-
description: `Seeded ${browserType} profile`,
|
|
700
|
-
browser: browserType,
|
|
701
|
-
binary,
|
|
702
|
-
endpoints: [`cdp://127.0.0.1:${freePort}`],
|
|
703
|
-
viewport: { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height },
|
|
704
|
-
});
|
|
705
|
-
console.log(`+ ${name} (${browserType})`);
|
|
706
|
-
}
|
|
707
|
-
console.log('Pick your default with: agents browser use <name>');
|
|
708
|
-
});
|
|
709
|
-
profiles
|
|
710
|
-
.command('claim [name]')
|
|
711
|
-
.description('Move leftover central browser: profiles into this device\'s declaration file. Only profiles this machine can host are claimed; the rest stay central. Run on the machine that actually has the browser.')
|
|
712
|
-
.option('--json', 'Output machine-readable JSON')
|
|
713
|
-
.action(async (name, opts) => {
|
|
714
|
-
const canHostHere = (config) => isProfileLaunchableHere({
|
|
715
|
-
name: '_',
|
|
716
|
-
browser: config.browser,
|
|
717
|
-
binary: config.binary,
|
|
718
|
-
endpoints: config.endpoints,
|
|
719
|
-
});
|
|
720
|
-
let result;
|
|
721
|
-
try {
|
|
722
|
-
result = migrateCentralBrowserProfiles(canHostHere, name);
|
|
723
|
-
}
|
|
724
|
-
catch (err) {
|
|
725
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
726
|
-
process.exit(1);
|
|
727
|
-
}
|
|
728
|
-
if (opts.json) {
|
|
729
|
-
console.log(JSON.stringify({ ...result, device: machineId() }, null, 2));
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
if (result.claimed.length === 0 && result.skipped.length === 0) {
|
|
733
|
-
console.log('No leftover central browser profiles to claim.');
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
for (const claimed of result.claimed) {
|
|
737
|
-
console.log(`Claimed ${claimed} on ${machineId()}`);
|
|
738
|
-
}
|
|
739
|
-
for (const skipped of result.skipped) {
|
|
740
|
-
console.log(`Skipped ${skipped}: this machine cannot host it (browser/binary isn't installed here). ` +
|
|
741
|
-
`Run \`agents browser profiles claim ${skipped}\` on the machine that has that browser.`);
|
|
742
|
-
}
|
|
743
|
-
if (result.claimed.length === 0) {
|
|
744
|
-
console.log('No leftover central profiles this machine can host.');
|
|
745
|
-
}
|
|
746
|
-
});
|
|
747
|
-
configureBrowserUseCommand(profiles.command('use [name]'));
|
|
748
|
-
configureBrowserUseCommand(profiles.command('set-default [name]', { hidden: true }), true);
|
|
749
|
-
profiles
|
|
750
|
-
.command('logins')
|
|
751
|
-
.description('Show which login-gated services each profile has a live session for, the account signed in, and whether login creds are available in the profile\'s secrets bundle (reads cookie/username presence only, never decrypts).')
|
|
752
|
-
.action(async () => {
|
|
753
|
-
const allProfiles = await listProfiles();
|
|
754
|
-
if (allProfiles.length === 0) {
|
|
755
|
-
console.log('No browser profiles configured.');
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
console.log('PROFILE'.padEnd(20) + 'SERVICE'.padEnd(12) + 'ACCOUNT'.padEnd(32) + 'CREDS');
|
|
759
|
-
console.log('-'.repeat(80));
|
|
760
|
-
for (const p of allProfiles) {
|
|
761
|
-
const rows = await loginsWithAccountsForProfile(p.name);
|
|
762
|
-
if (rows.length === 0) {
|
|
763
|
-
console.log(p.name.padEnd(20) + '(none detected)');
|
|
764
|
-
continue;
|
|
765
|
-
}
|
|
766
|
-
let first = true;
|
|
767
|
-
for (const r of rows) {
|
|
768
|
-
const name = first ? p.name : '';
|
|
769
|
-
console.log(name.padEnd(20) +
|
|
770
|
-
r.service.padEnd(12) +
|
|
771
|
-
(r.username ?? '(unknown)').padEnd(32) +
|
|
772
|
-
(await credAvailability(p.secrets, r.service)));
|
|
773
|
-
first = false;
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
});
|
|
777
|
-
const VALID_BROWSERS = ['chrome', 'comet', 'chromium', 'brave', 'edge', 'arc', 'custom'];
|
|
778
|
-
profiles
|
|
779
|
-
.command('create <name>')
|
|
780
|
-
.alias('add')
|
|
781
|
-
.description('Create a new browser profile on this device')
|
|
782
|
-
.requiredOption('-b, --browser <type>', `Browser type: ${VALID_BROWSERS.join(', ')}`)
|
|
783
|
-
.option('-e, --endpoint <url>', 'CDP endpoint URL (repeatable; auto-assigned if omitted)', collect, [])
|
|
784
|
-
.option('-s, --secrets <bundle>', 'Secrets bundle to inject')
|
|
785
|
-
.option('-d, --description <text>', 'Profile description')
|
|
786
|
-
.option('--headless', 'Run in headless mode')
|
|
787
|
-
.option('--window <WxH>', `Window size in CSS pixels (default: ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}, MacBook Pro 14")`)
|
|
788
|
-
.option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
|
|
789
|
-
.option('--binary <path>', 'Absolute path to the browser/app binary (required with --browser custom)')
|
|
790
|
-
.option('--electron', 'Treat this profile as an Electron desktop app: never call Target.createTarget; bind to the visible window using --target-filter or the skip-invisible heuristic')
|
|
791
|
-
.option('--target-filter <expr>', 'Pick the existing CDP page target to drive (Electron apps, and Arc — which reuses an open tab / Space rather than creating one). Format: url:<substring> or title:<substring>')
|
|
792
|
-
.option('--attach-only', 'Never spawn a rival window: attach to a browser you already started with remote debugging, else fail loud with a relaunch hint (PHNX-3967). The model for a canonical signed-in Comet; Arc is always attach-only. Pairs with a durable --user-data-dir.')
|
|
793
|
-
.option('--user-data-dir <path>', "Absolute durable --user-data-dir for this profile's browser. Default for attach-only: ~/.agents/.history/browser-profiles/<name>/chrome-data (outside .cache, so a one-time sign-in survives relaunch).")
|
|
794
|
-
.action(async (name, opts) => {
|
|
795
|
-
try {
|
|
796
|
-
assertRegistrableProfileName(name);
|
|
797
|
-
}
|
|
798
|
-
catch (err) {
|
|
799
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
800
|
-
process.exit(1);
|
|
801
|
-
}
|
|
802
|
-
if (!VALID_BROWSERS.includes(opts.browser)) {
|
|
803
|
-
console.error(`Invalid browser type. Must be one of: ${VALID_BROWSERS.join(', ')}`);
|
|
804
|
-
process.exit(1);
|
|
805
|
-
}
|
|
806
|
-
if (opts.browser === 'custom' && !opts.binary) {
|
|
807
|
-
console.error('--browser custom requires --binary <path>');
|
|
808
|
-
process.exit(1);
|
|
809
|
-
}
|
|
810
|
-
if (opts.targetFilter) {
|
|
811
|
-
// Route through the same parser the runtime uses so the CLI gate matches
|
|
812
|
-
// the runtime contract — `url:` (empty value) and `url: foo` (leading
|
|
813
|
-
// whitespace) both pass a naive `kind` check but produce a silent
|
|
814
|
-
// heuristic fallback at runtime.
|
|
815
|
-
const parsed = parseTargetFilter(String(opts.targetFilter));
|
|
816
|
-
if (!parsed) {
|
|
817
|
-
console.error('--target-filter must be url:<substring> or title:<substring> (non-empty value, no leading whitespace)');
|
|
818
|
-
process.exit(1);
|
|
819
|
-
}
|
|
820
|
-
// The filter picks WHICH existing page target to drive without ever
|
|
821
|
-
// calling Target.createTarget — the two profiles that do that are
|
|
822
|
-
// Electron apps and Arc (which crashes on tab creation, so it drives an
|
|
823
|
-
// existing tab / Space, PHNX-2399). Any other browser opens its own tab
|
|
824
|
-
// and never consults the filter, so requiring it there would be a lie.
|
|
825
|
-
if (!opts.electron && opts.browser !== 'arc') {
|
|
826
|
-
console.error('--target-filter requires --electron or --browser arc (the filter is only consulted on profiles that reuse an existing tab)');
|
|
827
|
-
process.exit(1);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
// Auto-assign a free port if no endpoint was provided
|
|
831
|
-
let endpoints = opts.endpoint;
|
|
832
|
-
if (endpoints.length === 0) {
|
|
833
|
-
const freePort = await findFreeProfilePort();
|
|
834
|
-
endpoints = [`cdp://127.0.0.1:${freePort}`];
|
|
835
|
-
}
|
|
836
|
-
// Viewport is mandatory — default to MacBook Pro 14" (1512x982) if
|
|
837
|
-
// --window is not provided. See lib/browser/devices.ts DEFAULT_VIEWPORT.
|
|
838
|
-
let viewport = {
|
|
839
|
-
width: DEFAULT_VIEWPORT.width,
|
|
840
|
-
height: DEFAULT_VIEWPORT.height,
|
|
841
|
-
};
|
|
842
|
-
if (opts.window) {
|
|
843
|
-
const size = parseWindowSize(String(opts.window));
|
|
844
|
-
if (!size) {
|
|
845
|
-
console.error(`--window must be WxH, e.g. ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`);
|
|
846
|
-
process.exit(1);
|
|
847
|
-
}
|
|
848
|
-
viewport.width = size.width;
|
|
849
|
-
viewport.height = size.height;
|
|
850
|
-
}
|
|
851
|
-
if (opts.position) {
|
|
852
|
-
const pos = parseWindowPosition(String(opts.position));
|
|
853
|
-
if (!pos) {
|
|
854
|
-
console.error('--position must be X,Y, e.g. 80,80');
|
|
855
|
-
process.exit(1);
|
|
856
|
-
}
|
|
857
|
-
viewport.x = pos.x;
|
|
858
|
-
viewport.y = pos.y;
|
|
859
|
-
}
|
|
860
|
-
if (opts.userDataDir && !path.isAbsolute(String(opts.userDataDir))) {
|
|
861
|
-
console.error('--user-data-dir must be an absolute path');
|
|
862
|
-
process.exit(1);
|
|
863
|
-
}
|
|
864
|
-
const profile = {
|
|
865
|
-
name,
|
|
866
|
-
description: opts.description,
|
|
867
|
-
browser: opts.browser,
|
|
868
|
-
binary: opts.binary,
|
|
869
|
-
electron: opts.electron || undefined,
|
|
870
|
-
targetFilter: opts.targetFilter,
|
|
871
|
-
endpoints,
|
|
872
|
-
launchPolicy: opts.attachOnly ? 'attach-only' : undefined,
|
|
873
|
-
userDataDir: opts.userDataDir ? String(opts.userDataDir) : undefined,
|
|
874
|
-
secrets: opts.secrets,
|
|
875
|
-
chrome: opts.headless ? { headless: true } : undefined,
|
|
876
|
-
viewport,
|
|
877
|
-
};
|
|
878
|
-
await createProfile(profile);
|
|
879
|
-
const port = extractConfiguredPort(profile);
|
|
880
|
-
console.log(port !== undefined
|
|
881
|
-
? `Added "${name}" on ${machineId()} (port ${port}).`
|
|
882
|
-
: `Added "${name}" on ${machineId()}.`);
|
|
883
|
-
// Attach-only profiles never launch a browser — tell the user the one-time
|
|
884
|
-
// relaunch that makes their canonical instance attachable, pinned to the
|
|
885
|
-
// durable data dir so the sign-in persists (PHNX-3967).
|
|
886
|
-
if (isAttachOnlyProfile(profile) && profile.browser !== 'arc' && port !== undefined) {
|
|
887
|
-
const app = profile.browser === 'comet' ? 'Comet' : profile.browser;
|
|
888
|
-
console.log(`attach-only: agents will not spawn ${app}. Start the canonical one once with:\n` +
|
|
889
|
-
` open -a ${app} --args --remote-debugging-port=${port} --user-data-dir=${resolveProfileDataDir(profile)}\n` +
|
|
890
|
-
`Sign in in that window; the data dir is durable so the login survives quit+relaunch.`);
|
|
891
|
-
}
|
|
892
|
-
// Warn (don't fail) if the declared secrets bundle doesn't exist yet — it
|
|
893
|
-
// may be created later, but a typo should surface now.
|
|
894
|
-
if (opts.secrets && !(await bundleExists(opts.secrets))) {
|
|
895
|
-
console.error(`warning: secrets bundle "${opts.secrets}" does not exist yet. Create it with: agents secrets create ${opts.secrets}`);
|
|
896
|
-
}
|
|
897
|
-
});
|
|
898
|
-
profiles
|
|
899
|
-
.command('edit <name>')
|
|
900
|
-
.description('Edit an existing profile in place (stays in the store it already lives in)')
|
|
901
|
-
// Declared only so the action can explain WHY the browser type is not
|
|
902
|
-
// editable. Without it commander rejects `-b` as an unknown option before
|
|
903
|
-
// the action runs, and the delete-and-recreate guidance never reaches anyone.
|
|
904
|
-
.option('-b, --browser <type>', 'Not editable — see the error for the delete-and-recreate path')
|
|
905
|
-
.option('-d, --description <text>', "Profile description (pass '' to clear)")
|
|
906
|
-
.option('-e, --endpoint <url>', 'Replace the endpoint list (repeatable)', collect, [])
|
|
907
|
-
.option('-s, --secrets <bundle>', 'Secrets bundle to inject')
|
|
908
|
-
.option('--headless', 'Run in headless mode')
|
|
909
|
-
.option('--no-headless', 'Run headed')
|
|
910
|
-
.option('--window <WxH>', 'Window size in CSS pixels')
|
|
911
|
-
.option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
|
|
912
|
-
.option('--binary <path>', 'Absolute path to the browser/app binary')
|
|
913
|
-
.option('--electron', 'Treat this profile as an Electron desktop app')
|
|
914
|
-
.option('--no-electron', 'Stop treating it as an Electron app')
|
|
915
|
-
.option('--target-filter <expr>', "url:<substring> or title:<substring>; consulted on Electron and Arc profiles (pass '' to clear)")
|
|
916
|
-
.option('--attach-only', 'Make this profile attach-only (never spawn a rival window) — PHNX-3967')
|
|
917
|
-
.option('--launch', 'Undo attach-only: let agents launch the browser when nothing is serving CDP on the port')
|
|
918
|
-
.option('--user-data-dir <path>', "Absolute durable --user-data-dir (pass '' to clear back to the default durable dir)")
|
|
919
|
-
.option('--json', 'Output machine-readable JSON')
|
|
920
|
-
.action(async (name, opts) => {
|
|
921
|
-
// The browser type and the name are identity, not settings: both key the
|
|
922
|
-
// on-disk runtime dir and any live `<name>@<endpoint>` connection, so
|
|
923
|
-
// changing either orphans the cached browser data (and its logins).
|
|
924
|
-
if (opts.browser) {
|
|
925
|
-
console.error('browser type is not editable — it keys the on-disk profile cache. ' +
|
|
926
|
-
`Remove and recreate instead: agents browser profiles remove ${name} && agents browser profiles create ${name} -b <type>`);
|
|
927
|
-
process.exit(1);
|
|
928
|
-
}
|
|
929
|
-
if (opts.targetFilter) {
|
|
930
|
-
const parsed = parseTargetFilter(String(opts.targetFilter));
|
|
931
|
-
if (!parsed) {
|
|
932
|
-
console.error('--target-filter must be url:<substring> or title:<substring> (non-empty value, no leading whitespace)');
|
|
933
|
-
process.exit(1);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
if (opts.attachOnly && opts.launch) {
|
|
937
|
-
console.error('Pass either --attach-only or --launch, not both.');
|
|
938
|
-
process.exit(1);
|
|
939
|
-
}
|
|
940
|
-
if (opts.userDataDir && !path.isAbsolute(String(opts.userDataDir))) {
|
|
941
|
-
console.error('--user-data-dir must be an absolute path');
|
|
942
|
-
process.exit(1);
|
|
943
|
-
}
|
|
944
|
-
const patch = {};
|
|
945
|
-
if (opts.description !== undefined)
|
|
946
|
-
patch.description = opts.description || undefined;
|
|
947
|
-
if (opts.endpoint && opts.endpoint.length > 0)
|
|
948
|
-
patch.endpoints = opts.endpoint;
|
|
949
|
-
if (opts.secrets !== undefined)
|
|
950
|
-
patch.secrets = opts.secrets;
|
|
951
|
-
if (opts.binary !== undefined)
|
|
952
|
-
patch.binary = opts.binary;
|
|
953
|
-
if (opts.targetFilter !== undefined)
|
|
954
|
-
patch.targetFilter = opts.targetFilter || undefined;
|
|
955
|
-
// launchPolicy toggle: --attach-only sets it, --launch clears it (back to
|
|
956
|
-
// the default launch behavior). commander leaves each undefined when unset.
|
|
957
|
-
if (opts.attachOnly)
|
|
958
|
-
patch.launchPolicy = 'attach-only';
|
|
959
|
-
else if (opts.launch)
|
|
960
|
-
patch.launchPolicy = undefined;
|
|
961
|
-
// '' clears back to the default durable dir; any other value pins it.
|
|
962
|
-
if (opts.userDataDir !== undefined)
|
|
963
|
-
patch.userDataDir = opts.userDataDir || undefined;
|
|
964
|
-
// commander maps --electron/--no-electron and --headless/--no-headless onto
|
|
965
|
-
// one boolean each, and leaves it undefined when neither was passed.
|
|
966
|
-
if (opts.electron !== undefined)
|
|
967
|
-
patch.electron = opts.electron || undefined;
|
|
968
|
-
if (opts.headless !== undefined || opts.window || opts.position) {
|
|
969
|
-
const current = await getProfile(name);
|
|
970
|
-
if (!current) {
|
|
971
|
-
console.error(`Profile "${name}" does not exist`);
|
|
972
|
-
process.exit(1);
|
|
973
|
-
}
|
|
974
|
-
if (opts.headless !== undefined) {
|
|
975
|
-
const chrome = { ...current.chrome };
|
|
976
|
-
if (opts.headless)
|
|
977
|
-
chrome.headless = true;
|
|
978
|
-
else
|
|
979
|
-
delete chrome.headless;
|
|
980
|
-
// `{ headless: undefined }` is truthy, so profileToConfig would persist
|
|
981
|
-
// a bare `chrome: {}` into the YAML and the change-detector would report
|
|
982
|
-
// a phantom edit on an already-headed profile. Drop the key entirely.
|
|
983
|
-
patch.chrome = Object.keys(chrome).length > 0 ? chrome : undefined;
|
|
984
|
-
}
|
|
985
|
-
if (opts.window || opts.position) {
|
|
986
|
-
const viewport = { ...(current.viewport ?? { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height }) };
|
|
987
|
-
if (opts.window) {
|
|
988
|
-
const size = parseWindowSize(String(opts.window));
|
|
989
|
-
if (!size) {
|
|
990
|
-
console.error('--window must be WxH, e.g. 1512x982');
|
|
991
|
-
process.exit(1);
|
|
992
|
-
}
|
|
993
|
-
viewport.width = size.width;
|
|
994
|
-
viewport.height = size.height;
|
|
995
|
-
}
|
|
996
|
-
if (opts.position) {
|
|
997
|
-
const pos = parseWindowPosition(String(opts.position));
|
|
998
|
-
if (!pos) {
|
|
999
|
-
console.error('--position must be X,Y, e.g. 80,80');
|
|
1000
|
-
process.exit(1);
|
|
1001
|
-
}
|
|
1002
|
-
viewport.x = pos.x;
|
|
1003
|
-
viewport.y = pos.y;
|
|
1004
|
-
}
|
|
1005
|
-
patch.viewport = viewport;
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
if (Object.keys(patch).length === 0) {
|
|
1009
|
-
console.error('Nothing to edit. Pass at least one field, e.g. -d "<description>".');
|
|
1010
|
-
process.exit(1);
|
|
1011
|
-
}
|
|
1012
|
-
let result;
|
|
1013
|
-
try {
|
|
1014
|
-
result = await editProfile(name, patch);
|
|
1015
|
-
}
|
|
1016
|
-
catch (err) {
|
|
1017
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1018
|
-
process.exit(1);
|
|
1019
|
-
}
|
|
1020
|
-
if (opts.json) {
|
|
1021
|
-
console.log(JSON.stringify({ ...result.profile, devices: result.devices, changed: result.changed }, null, 2));
|
|
1022
|
-
}
|
|
1023
|
-
else if (result.changed.length === 0) {
|
|
1024
|
-
console.log(`No change: ${name} already had those values.`);
|
|
1025
|
-
}
|
|
1026
|
-
else {
|
|
1027
|
-
console.log(`Updated ${name} on ${machineId()}: ${result.changed.join(', ')}`);
|
|
1028
|
-
}
|
|
1029
|
-
if (patch.secrets && !(await bundleExists(patch.secrets))) {
|
|
1030
|
-
console.error(`warning: secrets bundle "${patch.secrets}" does not exist yet. Create it with: agents secrets create ${patch.secrets}`);
|
|
1031
|
-
}
|
|
1032
|
-
});
|
|
1033
|
-
profiles
|
|
1034
|
-
.command('rename <from> <to>')
|
|
1035
|
-
.description("Rename a profile, moving its browser data with it (logins survive)")
|
|
1036
|
-
.option('--json', 'Output machine-readable JSON')
|
|
1037
|
-
.action(async (from, to, opts) => {
|
|
1038
|
-
let res;
|
|
1039
|
-
try {
|
|
1040
|
-
res = await renameProfile(from, to);
|
|
1041
|
-
}
|
|
1042
|
-
catch (err) {
|
|
1043
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1044
|
-
process.exit(1);
|
|
1045
|
-
}
|
|
1046
|
-
if (opts.json) {
|
|
1047
|
-
console.log(JSON.stringify({ from, to, ...res }, null, 2));
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
console.log(`Renamed ${from} -> ${to} on ${machineId()}`);
|
|
1051
|
-
if (res.movedDirs.length > 0) {
|
|
1052
|
-
console.log(` moved ${res.movedDirs.length} browser data dir${res.movedDirs.length === 1 ? '' : 's'} — logins preserved`);
|
|
1053
|
-
}
|
|
1054
|
-
if (res.repointedDefault) {
|
|
1055
|
-
console.log(` browser.profile now points at ${to}`);
|
|
1056
|
-
}
|
|
1057
|
-
if (res.repointedViewer) {
|
|
1058
|
-
console.log(` browser.viewer now points at ${to}`);
|
|
1059
|
-
}
|
|
1060
|
-
if (res.stalePins.length > 0) {
|
|
1061
|
-
const devices = [...new Set(res.stalePins.map((p) => p.device))];
|
|
1062
|
-
const one = devices.length === 1;
|
|
1063
|
-
console.error(`warning: ${devices.join(', ')} still ${one ? 'pins' : 'pin'} "${from}" in ` +
|
|
1064
|
-
`${one ? 'its' : 'their'} own device config. Fix with:`);
|
|
1065
|
-
for (const pin of res.stalePins) {
|
|
1066
|
-
console.error(` agents config set ${pin.key} ${to} --device ${pin.device}`);
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
});
|
|
1070
|
-
profiles
|
|
1071
|
-
.command('show <name>')
|
|
1072
|
-
.description('Show profile details')
|
|
1073
|
-
.option('--json', 'Output machine-readable JSON')
|
|
1074
|
-
.action(async (name, opts) => {
|
|
1075
|
-
const profile = await getProfile(name);
|
|
1076
|
-
if (!profile) {
|
|
1077
|
-
if (opts.json) {
|
|
1078
|
-
console.log(JSON.stringify({ ok: false, error: `Profile "${name}" not found` }));
|
|
1079
|
-
}
|
|
1080
|
-
else {
|
|
1081
|
-
console.error(`Profile "${name}" not found`);
|
|
1082
|
-
}
|
|
1083
|
-
process.exit(1);
|
|
1084
|
-
}
|
|
1085
|
-
if (opts.json) {
|
|
1086
|
-
console.log(JSON.stringify(profile, null, 2));
|
|
1087
|
-
return;
|
|
1088
|
-
}
|
|
1089
|
-
console.log(`Name: ${profile.name}`);
|
|
1090
|
-
console.log(`Browser: ${profile.browser}`);
|
|
1091
|
-
if (getConfiguredDefaultProfileName() === profile.name) {
|
|
1092
|
-
console.log('Default: yes (this machine — used by `agents browser start`)');
|
|
1093
|
-
}
|
|
1094
|
-
if (profile.binary)
|
|
1095
|
-
console.log(`Binary: ${profile.binary}`);
|
|
1096
|
-
if (profile.electron)
|
|
1097
|
-
console.log(`Electron: true`);
|
|
1098
|
-
if (isAttachOnlyProfile(profile)) {
|
|
1099
|
-
const why = profile.browser === 'arc' ? ' (Arc is always attach-only)' : '';
|
|
1100
|
-
console.log(`Launch policy: attach-only${why} — agents attach, never spawn a rival window`);
|
|
1101
|
-
if (profile.browser !== 'arc') {
|
|
1102
|
-
console.log(`User-data-dir: ${resolveProfileDataDir(profile)} (durable)`);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
if (profile.targetFilter)
|
|
1106
|
-
console.log(`Target filter: ${profile.targetFilter}`);
|
|
1107
|
-
if (profile.description)
|
|
1108
|
-
console.log(`Description: ${profile.description}`);
|
|
1109
|
-
if (profile.arc) {
|
|
1110
|
-
console.log(`Arc Space: ${profile.arc.spaceTitle} (${profile.arc.spaceId})`);
|
|
1111
|
-
console.log(`Arc profile: ${profile.arc.profileName} (${profile.arc.profileId})`);
|
|
1112
|
-
}
|
|
1113
|
-
if (profile.firefox) {
|
|
1114
|
-
console.log(`Firefox profile: ${profile.firefox.profileName}${profile.firefox.isDefault ? ' (default)' : ''}`);
|
|
1115
|
-
if (profile.userDataDir)
|
|
1116
|
-
console.log(`Profile directory: ${profile.userDataDir}`);
|
|
1117
|
-
}
|
|
1118
|
-
const presets = getEndpointPresets(profile);
|
|
1119
|
-
const defaultName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
|
|
1120
|
-
? profile.defaultEndpoint
|
|
1121
|
-
: Object.keys(presets)[0];
|
|
1122
|
-
console.log('Endpoints:');
|
|
1123
|
-
for (const [presetName, preset] of Object.entries(presets)) {
|
|
1124
|
-
const marker = presetName === defaultName ? ' (default)' : '';
|
|
1125
|
-
const isLegacy = presetName.startsWith('endpoint-');
|
|
1126
|
-
console.log(` - ${isLegacy ? preset.target : `${presetName}: ${preset.target}`}${marker}`);
|
|
1127
|
-
if (preset.binary)
|
|
1128
|
-
console.log(` binary: ${preset.binary}`);
|
|
1129
|
-
if (preset.targetFilter)
|
|
1130
|
-
console.log(` targetFilter: ${preset.targetFilter}`);
|
|
1131
|
-
}
|
|
1132
|
-
if (profile.viewport) {
|
|
1133
|
-
const v = profile.viewport;
|
|
1134
|
-
const pos = v.x !== undefined && v.y !== undefined ? ` @ ${v.x},${v.y}` : '';
|
|
1135
|
-
console.log(`Viewport: ${v.width}×${v.height}${pos}`);
|
|
1136
|
-
}
|
|
1137
|
-
if (profile.secrets)
|
|
1138
|
-
console.log(`Secrets: ${profile.secrets}`);
|
|
1139
|
-
if (profile.chrome?.headless)
|
|
1140
|
-
console.log(`Headless: true`);
|
|
1141
|
-
// Login state per known service: live session + account identity + whether
|
|
1142
|
-
// login creds are declared in the profile's secrets bundle.
|
|
1143
|
-
if (profile.arc || profile.firefox)
|
|
1144
|
-
return;
|
|
1145
|
-
const active = await loginsForProfile(profile.name);
|
|
1146
|
-
const accounts = await accountsForProfile(profile.name);
|
|
1147
|
-
const lines = [];
|
|
1148
|
-
for (const service of Object.keys(AUTH_SIGNATURES)) {
|
|
1149
|
-
const isActive = active.includes(service);
|
|
1150
|
-
const creds = await credAvailability(profile.secrets, service);
|
|
1151
|
-
if (!isActive && creds === '-')
|
|
1152
|
-
continue; // nothing interesting to show
|
|
1153
|
-
const status = isActive
|
|
1154
|
-
? `logged in${accounts[service] ? ` as ${accounts[service]}` : ''}`
|
|
1155
|
-
: 'logged out';
|
|
1156
|
-
const credNote = creds !== '-' ? ` creds: ${creds}` : '';
|
|
1157
|
-
lines.push(` ${service.padEnd(10)} ${status}${credNote}`);
|
|
1158
|
-
}
|
|
1159
|
-
if (lines.length > 0) {
|
|
1160
|
-
console.log('Logins:');
|
|
1161
|
-
for (const l of lines)
|
|
1162
|
-
console.log(l);
|
|
1163
|
-
}
|
|
1164
|
-
});
|
|
1165
|
-
profiles
|
|
1166
|
-
.command('remove <name>')
|
|
1167
|
-
.alias('delete')
|
|
1168
|
-
.description('Remove the agents-cli profile alias and cached runtime dirs (never deletes native Arc data)')
|
|
1169
|
-
.option('--keep-cache', "Leave ~/.agents/.cache/browser/<name>* dirs in place (don't wipe chrome-data)")
|
|
1170
|
-
.action(async (name, opts) => {
|
|
1171
|
-
await deleteProfile(name);
|
|
1172
|
-
// The composite naming change introduced multiple cache dirs per
|
|
1173
|
-
// profile (`<name>`, `<name>@endpoint-0`, …). Sweep them all unless
|
|
1174
|
-
// the user explicitly wants the chrome-data preserved (e.g. for
|
|
1175
|
-
// re-import into a freshly-created profile of the same name).
|
|
1176
|
-
let removed = 0;
|
|
1177
|
-
if (!opts.keepCache) {
|
|
1178
|
-
const cacheDirs = listProfileCacheDirs(name);
|
|
1179
|
-
removed = cacheDirs.length;
|
|
1180
|
-
for (const dir of cacheDirs) {
|
|
1181
|
-
// `removeProfileCache` operates by profile-name; for the
|
|
1182
|
-
// composite dirs we already have the absolute path. Use rmSync
|
|
1183
|
-
// directly so we don't depend on naming round-trips.
|
|
1184
|
-
try {
|
|
1185
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1186
|
-
}
|
|
1187
|
-
catch { /* ignore */ }
|
|
1188
|
-
}
|
|
1189
|
-
// The canonical wipe also covers the legacy dir if present.
|
|
1190
|
-
removeProfileCache(name);
|
|
1191
|
-
}
|
|
1192
|
-
console.log(`Deleted profile: ${name}` +
|
|
1193
|
-
(removed > 0 ? ` (and ${removed} cache dir${removed === 1 ? '' : 's'})` : ''));
|
|
1194
|
-
if (getConfiguredDefaultProfileName() === name) {
|
|
1195
|
-
console.error(`warning: "${name}" was this machine's default browser profile; ` +
|
|
1196
|
-
`\`agents browser start\` has no default here until you run: agents browser use <name>`);
|
|
1197
|
-
}
|
|
1198
|
-
});
|
|
1199
|
-
profiles
|
|
1200
|
-
.command('prune')
|
|
1201
|
-
.description('Remove dead profiles this device declares: browser not installed here, or never started')
|
|
1202
|
-
.option('-n, --dry-run', 'Print what would be removed and exit without changing anything')
|
|
1203
|
-
.option('--json', 'Output machine-readable JSON')
|
|
1204
|
-
.action(async (opts) => {
|
|
1205
|
-
const plan = await buildProfilePrunePlan();
|
|
1206
|
-
if (!opts.dryRun)
|
|
1207
|
-
await pruneProfiles(plan);
|
|
1208
|
-
if (opts.json) {
|
|
1209
|
-
console.log(JSON.stringify({ dryRun: !!opts.dryRun, ...plan }, null, 2));
|
|
1210
|
-
return;
|
|
1211
|
-
}
|
|
1212
|
-
// A misfiled profile is never a prune candidate — the entry belongs to the
|
|
1213
|
-
// device that declared it, not to this one — so without this it would only
|
|
1214
|
-
// ever surface in --json. It is the one kept-reason a user has to act on.
|
|
1215
|
-
const misfiled = plan.kept.filter((k) => k.misfiled);
|
|
1216
|
-
const reportMisfiled = () => {
|
|
1217
|
-
if (misfiled.length === 0)
|
|
1218
|
-
return;
|
|
1219
|
-
console.log('');
|
|
1220
|
-
console.log(`${misfiled.length} profile${misfiled.length === 1 ? '' : 's'} ${misfiled.length === 1 ? 'is' : 'are'} misfiled — ` +
|
|
1221
|
-
`declared on another device but bound to a loopback port, so the name resolves to ` +
|
|
1222
|
-
`this machine's own browser rather than the declaring device's:`);
|
|
1223
|
-
for (const k of misfiled) {
|
|
1224
|
-
console.log(` ${k.name} — ${k.why}`);
|
|
1225
|
-
}
|
|
1226
|
-
console.log('Re-declare each on the machine that owns the browser. Nothing was moved for you.');
|
|
1227
|
-
console.log('(Prune never deletes these — the declaring device owns the entry.)');
|
|
1228
|
-
};
|
|
1229
|
-
if (plan.candidates.length === 0) {
|
|
1230
|
-
console.log('Nothing to prune — every profile is in use, healthy, or protected.');
|
|
1231
|
-
if (plan.kept.length > 0) {
|
|
1232
|
-
for (const k of plan.kept)
|
|
1233
|
-
console.log(` kept ${k.name} (${k.scope}) — ${k.why}`);
|
|
1234
|
-
}
|
|
1235
|
-
reportMisfiled();
|
|
1236
|
-
return;
|
|
1237
|
-
}
|
|
1238
|
-
const verb = opts.dryRun ? 'Would remove' : 'Removed';
|
|
1239
|
-
console.log(`${verb} ${plan.candidates.length} profile${plan.candidates.length === 1 ? '' : 's'}:`);
|
|
1240
|
-
for (const c of plan.candidates) {
|
|
1241
|
-
const cache = c.cacheDirs.length > 0
|
|
1242
|
-
? ` + ${c.cacheDirs.length} cache dir${c.cacheDirs.length === 1 ? '' : 's'}`
|
|
1243
|
-
: '';
|
|
1244
|
-
console.log(` ${c.name} (${c.scope}) — ${PRUNE_REASON_TEXT[c.reason]}${cache}`);
|
|
1245
|
-
}
|
|
1246
|
-
reportMisfiled();
|
|
1247
|
-
if (opts.dryRun) {
|
|
1248
|
-
console.log('');
|
|
1249
|
-
console.log('Nothing was changed. Re-run without --dry-run to apply.');
|
|
1250
|
-
}
|
|
1251
|
-
});
|
|
1252
|
-
setHelpSections(profiles, {
|
|
1253
|
-
examples: `
|
|
1254
|
-
# WHERE is the devices whose own file declares the name
|
|
1255
|
-
agents browser profiles list
|
|
1256
|
-
|
|
1257
|
-
# Create one on this device
|
|
1258
|
-
agents browser profiles add work --browser chrome
|
|
1259
|
-
agents browser profiles create work --browser chrome
|
|
1260
|
-
|
|
1261
|
-
# Claim leftover central profiles on the machine that hosts the browser
|
|
1262
|
-
agents browser profiles claim
|
|
1263
|
-
agents browser profiles claim comet-local
|
|
1264
|
-
|
|
1265
|
-
# Clean up dead ones this device declares: check first, then apply
|
|
1266
|
-
agents browser profiles prune --dry-run
|
|
1267
|
-
agents browser profiles prune
|
|
1268
|
-
|
|
1269
|
-
# Make one the machine's default for a bare \`agents browser start\`
|
|
1270
|
-
agents browser use work
|
|
214
|
+
# A remote box over the fleet (device bound at start)
|
|
215
|
+
agents browser start --device box --profile work
|
|
216
|
+
agents browser navigate https://example.com
|
|
217
|
+
agents browser done
|
|
1271
218
|
`,
|
|
1272
219
|
notes: `
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
In \`list\`, the \`*\` marker means "this machine's configured default"
|
|
1279
|
-
(\`agents browser start\` with no --profile). A profile a setup wizard pins
|
|
1280
|
-
is named \`auto-chrome\`; \`default\` is only an ALIAS for whichever profile
|
|
1281
|
-
this machine is configured to use, resolved the same way by every command.
|
|
220
|
+
The engine is the standalone \`browser\` CLI (npm i -g @phnx-labs/browser-cli);
|
|
221
|
+
agents-cli supplies --device fleet resolution, remote-control consent, and the
|
|
222
|
+
session/feed history. Per-verb flags are the engine's — \`agents browser
|
|
223
|
+
screenshot --help\` asks it directly.
|
|
1282
224
|
|
|
1283
|
-
\`
|
|
1284
|
-
|
|
1285
|
-
profile a setup wizard created.
|
|
225
|
+
\`--device\` is bound once at \`start\`; page verbs run against the task's bound
|
|
226
|
+
device. \`--device local\` forces this machine.
|
|
1286
227
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
already carries its Arc profile's logins, so it IS the browser profile.
|
|
1290
|
-
Agents attach to the running Arc through Apple Events and never launch or
|
|
1291
|
-
relaunch it. Comet's own profiles are listed the same way (comet-work, ...),
|
|
1292
|
-
pinned to Comet's real user-data dir, so agents and you share one Comet
|
|
1293
|
-
window per profile: agents attach when it is running with remote debugging,
|
|
1294
|
-
launch it on your store when it is not running, and fail loud with the
|
|
1295
|
-
relaunch command when it runs without a port. The daemon publishes every
|
|
1296
|
-
discovered profile into this device's declaration, so other fleet boxes list
|
|
1297
|
-
it with WHERE=<this device> and route to it. Point agents at Comet when a workflow needs screenshots,
|
|
1298
|
-
downloads, network capture, or trusted input:
|
|
1299
|
-
agents browser profiles create agents-comet --browser comet --attach-only
|
|
1300
|
-
A CDP \`--attach-only\` profile attaches to a browser you
|
|
1301
|
-
already started with remote debugging and fails loud with the relaunch
|
|
1302
|
-
command otherwise, pinned to a durable --user-data-dir so the sign-in
|
|
1303
|
-
survives quit+relaunch. A foreign browser squatting the canonical port is
|
|
1304
|
-
rejected, not driven — \`agents browser profiles doctor <name>\` flags it.
|
|
1305
|
-
`,
|
|
1306
|
-
});
|
|
1307
|
-
profiles
|
|
1308
|
-
.command('doctor <name>')
|
|
1309
|
-
.description('Diagnose a browser profile: where it is declared, binary, port, user-data-dir, onboarding state')
|
|
1310
|
-
.action(async (name) => {
|
|
1311
|
-
const profile = await getProfile(name);
|
|
1312
|
-
if (!profile) {
|
|
1313
|
-
console.error(`Profile "${name}" not found`);
|
|
1314
|
-
process.exit(1);
|
|
1315
|
-
}
|
|
1316
|
-
if ((profile.arc || profile.firefox) && !isFleetRemoteInvocation()) {
|
|
1317
|
-
const routed = resolveBrowserTarget(profile.name);
|
|
1318
|
-
if (!routed.local && routed.commandDispatch) {
|
|
1319
|
-
const result = await dispatchBrowserToDevice(routed.device, ['browser', 'profiles', 'doctor', name], 'capture');
|
|
1320
|
-
process.stdout.write(result.stdout);
|
|
1321
|
-
process.stderr.write(result.stderr);
|
|
1322
|
-
if (result.code !== 0)
|
|
1323
|
-
process.exit(result.code);
|
|
1324
|
-
return;
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
const checks = [];
|
|
1328
|
-
// 0. Declaration topology. An identity-bearing profile declared by a
|
|
1329
|
-
// different device cannot be diagnosed through a loopback CDP endpoint
|
|
1330
|
-
// on this one — that is the original comet-local bug. Checked first
|
|
1331
|
-
// because it invalidates every local check.
|
|
1332
|
-
const misfiled = identityLoopbackMismatch(profile);
|
|
1333
|
-
const kind = profileKind(profile.name);
|
|
1334
|
-
const where = profile.devices.length > 0 ? profile.devices.join(', ') : 'no device';
|
|
1335
|
-
checks.push(misfiled.misfiled
|
|
1336
|
-
? { label: 'where', ok: false, detail: misfiled.why }
|
|
1337
|
-
: {
|
|
1338
|
-
label: 'where',
|
|
1339
|
-
ok: true,
|
|
1340
|
-
detail: kind
|
|
1341
|
-
? `${kind}, declared on ${where}`
|
|
1342
|
-
: `declared on ${where}`,
|
|
1343
|
-
});
|
|
1344
|
-
if (misfiled.misfiled) {
|
|
1345
|
-
for (const c of checks) {
|
|
1346
|
-
const marker = c.ok ? 'OK ' : 'FAIL';
|
|
1347
|
-
console.log(`${marker} ${c.label.padEnd(15)} ${c.detail}`);
|
|
1348
|
-
}
|
|
1349
|
-
process.exit(1);
|
|
1350
|
-
}
|
|
1351
|
-
if (profile.arc) {
|
|
1352
|
-
checks.push({
|
|
1353
|
-
label: 'space',
|
|
1354
|
-
ok: true,
|
|
1355
|
-
detail: `${profile.arc.spaceTitle} (${profile.arc.spaceId})`,
|
|
1356
|
-
});
|
|
1357
|
-
checks.push({
|
|
1358
|
-
label: 'arc-profile',
|
|
1359
|
-
ok: true,
|
|
1360
|
-
detail: `${profile.arc.profileName} (${profile.arc.profileId})`,
|
|
1361
|
-
});
|
|
1362
|
-
const running = await isArcRunning();
|
|
1363
|
-
checks.push({
|
|
1364
|
-
label: 'Arc process',
|
|
1365
|
-
ok: running,
|
|
1366
|
-
detail: running ? 'running; native automation attaches without launching or restarting Arc' : 'not running — start Arc normally',
|
|
1367
|
-
});
|
|
1368
|
-
const allOk = checks.every((check) => check.ok);
|
|
1369
|
-
for (const check of checks) {
|
|
1370
|
-
console.log(`${check.ok ? 'OK ' : 'FAIL'} ${check.label.padEnd(15)} ${check.detail}`);
|
|
1371
|
-
}
|
|
1372
|
-
if (!allOk)
|
|
1373
|
-
process.exit(1);
|
|
1374
|
-
return;
|
|
1375
|
-
}
|
|
1376
|
-
// 1. Binary exists for declared browser type, and is a real executable we
|
|
1377
|
-
// can drive — not a distro launcher script. findBrowserPath already
|
|
1378
|
-
// unwraps the known Chromium wrappers to their ELF; if it still hands
|
|
1379
|
-
// back a shebang script we couldn't resolve, `start` would fail with
|
|
1380
|
-
// `CDP connection closed` (the wrapper re-execs the browser as a child,
|
|
1381
|
-
// breaking the --remote-debugging-pipe transport — issue #229). Flag it
|
|
1382
|
-
// here instead of letting launch fail opaquely.
|
|
1383
|
-
try {
|
|
1384
|
-
const binPath = findBrowserPath(profile.browser, profile.binary);
|
|
1385
|
-
if (isLauncherScript(binPath)) {
|
|
1386
|
-
checks.push({
|
|
1387
|
-
label: 'binary',
|
|
1388
|
-
ok: false,
|
|
1389
|
-
detail: `${binPath} is a launcher script, not the browser executable — ` +
|
|
1390
|
-
`agents browser drives the browser over --remote-debugging-pipe and ` +
|
|
1391
|
-
`can't attach to a wrapper that re-execs it. Point the profile at the ` +
|
|
1392
|
-
`real binary (\`--binary /path/to/browser\`) or reinstall the standard package.`,
|
|
1393
|
-
});
|
|
1394
|
-
}
|
|
1395
|
-
else {
|
|
1396
|
-
checks.push({ label: 'binary', ok: true, detail: binPath });
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
catch (err) {
|
|
1400
|
-
checks.push({
|
|
1401
|
-
label: 'binary',
|
|
1402
|
-
ok: false,
|
|
1403
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
1404
|
-
});
|
|
1405
|
-
}
|
|
1406
|
-
// 2. Configured port. For local cdp:// we check the local port. For
|
|
1407
|
-
// ssh:// the port lives on a remote host — doctor's previous
|
|
1408
|
-
// behavior was to lsof the LOCAL port number, which was both
|
|
1409
|
-
// misleading and arbitrary (after the SSH-binds-locally change
|
|
1410
|
-
// the local port now matches the remote, so a positive answer
|
|
1411
|
-
// is plausible; but doctor still shouldn't report on remote
|
|
1412
|
-
// state without an --remote-probe explicitly).
|
|
1413
|
-
const port = extractConfiguredPort(profile);
|
|
1414
|
-
let attachingToExistingBrowser = false;
|
|
1415
|
-
const firstEndpointTarget = (() => {
|
|
1416
|
-
const presets = getEndpointPresets(profile);
|
|
1417
|
-
const first = Object.keys(presets)[0];
|
|
1418
|
-
return first ? presets[first].target : undefined;
|
|
1419
|
-
})();
|
|
1420
|
-
const isSshEndpoint = firstEndpointTarget?.startsWith('ssh://') ?? false;
|
|
1421
|
-
if (port === undefined) {
|
|
1422
|
-
checks.push({ label: 'port', ok: true, detail: 'no port in endpoint' });
|
|
1423
|
-
}
|
|
1424
|
-
else if (isSshEndpoint) {
|
|
1425
|
-
checks.push({
|
|
1426
|
-
label: 'port',
|
|
1427
|
-
ok: true,
|
|
1428
|
-
detail: `${port} (remote on ${firstEndpointTarget}) — skipping local check`,
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1431
|
-
else {
|
|
1432
|
-
const occupant = getPortOccupant(port);
|
|
1433
|
-
if (!occupant) {
|
|
1434
|
-
// A free port doesn't mean "ready to launch here": for a local
|
|
1435
|
-
// profile we self-launch over an internal --remote-debugging-pipe and
|
|
1436
|
-
// never bind this port. The port is consulted only to attach to a
|
|
1437
|
-
// browser someone already started on it. Say so, so a green doctor
|
|
1438
|
-
// can't be read as "the port is what launch depends on" (#229).
|
|
1439
|
-
checks.push({
|
|
1440
|
-
label: 'port',
|
|
1441
|
-
ok: true,
|
|
1442
|
-
detail: `${port} free — will self-launch over an internal pipe (port used only to attach to an already-running browser)`,
|
|
1443
|
-
});
|
|
1444
|
-
}
|
|
1445
|
-
else {
|
|
1446
|
-
try {
|
|
1447
|
-
const { browser } = await discoverBrowserWsUrl(port, 'localhost', profile.name);
|
|
1448
|
-
verifyBrowserIdentity(browser, profile.browser, port);
|
|
1449
|
-
// Port-squat check (PHNX-3967): for an attach-only profile, a browser
|
|
1450
|
-
// of the right FAMILY on the canonical port is not enough — confirm
|
|
1451
|
-
// its --user-data-dir is this profile's durable dir. A logged-out
|
|
1452
|
-
// /tmp Comet answering CDP here would otherwise pass as ready and get
|
|
1453
|
-
// driven as if it were the credentialed browser.
|
|
1454
|
-
const expectedDir = resolveProfileDataDir(profile);
|
|
1455
|
-
const ownershipChecked = isAttachOnlyProfile(profile) && profile.browser !== 'arc';
|
|
1456
|
-
const runningDir = ownershipChecked ? getProcessUserDataDir(occupant.pid) : null;
|
|
1457
|
-
if (runningDir && normalizeDataDir(runningDir) !== normalizeDataDir(expectedDir)) {
|
|
1458
|
-
checks.push({
|
|
1459
|
-
label: 'port',
|
|
1460
|
-
ok: false,
|
|
1461
|
-
detail: `${port} serving ${browser} (pid ${occupant.pid}) but from a FOREIGN ` +
|
|
1462
|
-
`user-data-dir ${runningDir} (expected ${expectedDir}). This is a ` +
|
|
1463
|
-
`port-squatter, not this profile's browser — agents will refuse to drive ` +
|
|
1464
|
-
`it. Close it (\`kill ${occupant.pid}\`) and relaunch the canonical browser ` +
|
|
1465
|
-
`with --user-data-dir=${expectedDir}.`,
|
|
1466
|
-
});
|
|
1467
|
-
}
|
|
1468
|
-
else if (ownershipChecked && !runningDir) {
|
|
1469
|
-
// Attach-only, an occupant is serving CDP, but its --user-data-dir
|
|
1470
|
-
// couldn't be read — ownership is UNVERIFIED. Surface it rather than
|
|
1471
|
-
// report a silent green, matching the runtime guard which refuses to
|
|
1472
|
-
// attach to an unverifiable instance (PHNX-3967).
|
|
1473
|
-
checks.push({
|
|
1474
|
-
label: 'port',
|
|
1475
|
-
ok: false,
|
|
1476
|
-
detail: `${port} serving ${browser} (pid ${occupant.pid}) but its --user-data-dir ` +
|
|
1477
|
-
`could not be read, so ownership can't be confirmed. agents will refuse to ` +
|
|
1478
|
-
`attach to an unverified instance. Relaunch the canonical browser with ` +
|
|
1479
|
-
`--user-data-dir=${expectedDir} so the attach-only guard can verify it.`,
|
|
1480
|
-
});
|
|
1481
|
-
}
|
|
1482
|
-
else {
|
|
1483
|
-
checks.push({
|
|
1484
|
-
label: 'port',
|
|
1485
|
-
ok: true,
|
|
1486
|
-
detail: `${port} serving ${browser} (pid ${occupant.pid})`,
|
|
1487
|
-
});
|
|
1488
|
-
attachingToExistingBrowser = true;
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
catch (err) {
|
|
1492
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1493
|
-
checks.push({
|
|
1494
|
-
label: 'port',
|
|
1495
|
-
ok: false,
|
|
1496
|
-
detail: `${port} taken by ${occupant.command} (pid ${occupant.pid}) — ${msg}`,
|
|
1497
|
-
});
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
// 3. User-data-dir exists and is writable. An attach-only profile's login
|
|
1502
|
-
// lives in the DURABLE dir (outside .cache) the canonical browser is
|
|
1503
|
-
// launched with; a launch-policy profile uses the managed cache dir.
|
|
1504
|
-
const userDataDir = isAttachOnlyProfile(profile) && profile.browser !== 'arc'
|
|
1505
|
-
? resolveProfileDataDir(profile)
|
|
1506
|
-
: path.join(getProfileRuntimeDir(name), 'chrome-data');
|
|
1507
|
-
try {
|
|
1508
|
-
if (!fs.existsSync(userDataDir)) {
|
|
1509
|
-
checks.push({
|
|
1510
|
-
label: 'user-data-dir',
|
|
1511
|
-
ok: true,
|
|
1512
|
-
detail: `will be created at ${userDataDir}`,
|
|
1513
|
-
});
|
|
1514
|
-
}
|
|
1515
|
-
else {
|
|
1516
|
-
fs.accessSync(userDataDir, fs.constants.W_OK);
|
|
1517
|
-
checks.push({ label: 'user-data-dir', ok: true, detail: userDataDir });
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
catch (err) {
|
|
1521
|
-
checks.push({
|
|
1522
|
-
label: 'user-data-dir',
|
|
1523
|
-
ok: false,
|
|
1524
|
-
detail: `${userDataDir} not writable: ${err instanceof Error ? err.message : err}`,
|
|
1525
|
-
});
|
|
1526
|
-
}
|
|
1527
|
-
// 4. Onboarding heuristic — only meaningful when WE will launch the
|
|
1528
|
-
// browser. When the configured port is already serving a debuggable
|
|
1529
|
-
// browser, that browser owns its own user-data-dir and the priming
|
|
1530
|
-
// status of our managed dir is irrelevant.
|
|
1531
|
-
if (attachingToExistingBrowser) {
|
|
1532
|
-
checks.push({
|
|
1533
|
-
label: 'onboarding',
|
|
1534
|
-
ok: true,
|
|
1535
|
-
detail: 'n/a (attaching to existing browser)',
|
|
1536
|
-
});
|
|
1537
|
-
}
|
|
1538
|
-
else {
|
|
1539
|
-
const localStatePath = path.join(userDataDir, 'Local State');
|
|
1540
|
-
if (fs.existsSync(localStatePath)) {
|
|
1541
|
-
const size = fs.statSync(localStatePath).size;
|
|
1542
|
-
if (size > 0) {
|
|
1543
|
-
checks.push({ label: 'onboarding', ok: true, detail: 'Local State present' });
|
|
1544
|
-
}
|
|
1545
|
-
else {
|
|
1546
|
-
checks.push({
|
|
1547
|
-
label: 'onboarding',
|
|
1548
|
-
ok: false,
|
|
1549
|
-
detail: 'Local State is empty — run `agents browser start --profile ' +
|
|
1550
|
-
name +
|
|
1551
|
-
'` and finish any first-run screens before automating',
|
|
1552
|
-
});
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
else {
|
|
1556
|
-
checks.push({
|
|
1557
|
-
label: 'onboarding',
|
|
1558
|
-
ok: false,
|
|
1559
|
-
detail: 'Not initialized yet — run `agents browser start --profile ' +
|
|
1560
|
-
name +
|
|
1561
|
-
'` and finish any first-run screens before automating',
|
|
1562
|
-
});
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1565
|
-
const allOk = checks.every((c) => c.ok);
|
|
1566
|
-
for (const c of checks) {
|
|
1567
|
-
const marker = c.ok ? 'OK ' : 'FAIL';
|
|
1568
|
-
console.log(`${marker} ${c.label.padEnd(15)} ${c.detail}`);
|
|
1569
|
-
}
|
|
1570
|
-
if (!allOk)
|
|
1571
|
-
process.exit(1);
|
|
1572
|
-
});
|
|
1573
|
-
}
|
|
1574
|
-
function registerTaskCommands(browser) {
|
|
1575
|
-
browser.hook('preAction', async (_thisCommand, actionCommand) => {
|
|
1576
|
-
inferredTaskName = undefined;
|
|
1577
|
-
if (isFleetRemoteInvocation())
|
|
1578
|
-
return;
|
|
1579
|
-
const pathNames = commandPath(actionCommand);
|
|
1580
|
-
const top = pathNames[0];
|
|
1581
|
-
if (!top || !TASK_ROUTED_COMMANDS.has(top))
|
|
1582
|
-
return;
|
|
1583
|
-
if (pathNames.length === 1 && top === 'start')
|
|
1584
|
-
return;
|
|
1585
|
-
const opts = actionCommand.opts();
|
|
1586
|
-
const deviceFlag = opts.device ?? flagValue(process.argv.slice(2), 'device', 'D');
|
|
1587
|
-
const taskFlag = opts.task ?? flagValue(process.argv.slice(2), 'task');
|
|
1588
|
-
// Reject --device on every later verb, including `stop --profile`.
|
|
1589
|
-
if (deviceFlag) {
|
|
1590
|
-
console.error(REJECT_DEVICE_MESSAGE);
|
|
1591
|
-
process.exit(1);
|
|
1592
|
-
}
|
|
1593
|
-
if (top === 'stop' && opts.profile && !taskFlag)
|
|
1594
|
-
return;
|
|
1595
|
-
// A native Arc endpoint cannot be represented by an SSH-forwarded socket.
|
|
1596
|
-
// For a cold profile-scoped page verb, create the task on the declaring
|
|
1597
|
-
// owner first and bind that owner locally; the ordinary task router below
|
|
1598
|
-
// then forwards this complete verb and every subsequent verb to that host.
|
|
1599
|
-
if (!taskFlag && opts.profile) {
|
|
1600
|
-
try {
|
|
1601
|
-
const profileName = (await resolveProfileRef(opts.profile)) ?? opts.profile;
|
|
1602
|
-
const target = resolveBrowserTarget(profileName);
|
|
1603
|
-
if (!target.local && target.commandDispatch) {
|
|
1604
|
-
const started = await dispatchBrowserToDevice(target.device, ['browser', 'start', '--profile', profileName, '--json'], 'capture');
|
|
1605
|
-
if (started.code !== 0) {
|
|
1606
|
-
process.stdout.write(started.stdout);
|
|
1607
|
-
process.stderr.write(started.stderr);
|
|
1608
|
-
process.exit(started.code);
|
|
1609
|
-
}
|
|
1610
|
-
const task = remoteStartTaskName(started.stdout);
|
|
1611
|
-
if (!task)
|
|
1612
|
-
throw new Error(`Remote start on ${target.device} produced no task name.`);
|
|
1613
|
-
bindTask(task, {
|
|
1614
|
-
device: target.device,
|
|
1615
|
-
profile: profileName,
|
|
1616
|
-
sessionId: callerSessionId(),
|
|
1617
|
-
launchId: callerLaunchId(),
|
|
1618
|
-
createdAt: Date.now(),
|
|
1619
|
-
});
|
|
1620
|
-
inferredTaskName = task;
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
catch (err) {
|
|
1624
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1625
|
-
process.exit(1);
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
const route = resolveTaskRoute({
|
|
1629
|
-
task: inferredTaskName ?? taskFlag ?? process.env.AGENTS_BROWSER_TASK,
|
|
1630
|
-
sessionId: callerSessionId(),
|
|
1631
|
-
launchId: callerLaunchId(),
|
|
1632
|
-
hub: defaultBrowserHub(),
|
|
1633
|
-
});
|
|
1634
|
-
if (route.kind !== 'proceed') {
|
|
1635
|
-
console.error(route.message);
|
|
1636
|
-
process.exit(1);
|
|
1637
|
-
}
|
|
1638
|
-
inferredTaskName = route.task;
|
|
1639
|
-
// A cold page verb (no bound task) whose route is the fleet hub still
|
|
1640
|
-
// forwards: `route.task` is undefined but `route.device` is a peer, so the
|
|
1641
|
-
// `--task` push below is skipped and the hub's daemon creates the caller's
|
|
1642
|
-
// OWN task (keyed to the identity `dispatchBrowserToDevice` now forwards).
|
|
1643
|
-
// Only a self-host route runs locally.
|
|
1644
|
-
if (isSelfHost(route.device))
|
|
1645
|
-
return;
|
|
1646
|
-
// ...but a cold CLOSE verb (done/stop) must NOT forward: with no task it
|
|
1647
|
-
// would target a browsing context this session never opened. It stays local
|
|
1648
|
-
// (its historic no-task behaviour) unless an explicit --task/local binding
|
|
1649
|
-
// routes it — a taskless `done` must never blindly stop a hub task.
|
|
1650
|
-
if (!route.task && isTerminalBrowserVerb(top))
|
|
1651
|
-
return;
|
|
1652
|
-
const forwarded = browserForwardedArgv();
|
|
1653
|
-
if (route.task && !forwarded.includes('--task')) {
|
|
1654
|
-
forwarded.push('--task', route.task);
|
|
1655
|
-
}
|
|
1656
|
-
const outputPath = top === 'screenshot'
|
|
1657
|
-
? actionCommand.opts().output ??
|
|
1658
|
-
flagValue(process.argv.slice(2), 'output', 'o')
|
|
1659
|
-
: undefined;
|
|
1660
|
-
if (top === 'screenshot' && outputPath) {
|
|
1661
|
-
const withoutOutput = stripRoutingFlags(forwarded, [
|
|
1662
|
-
{ long: 'output', short: 'o', takesValue: true },
|
|
1663
|
-
]);
|
|
1664
|
-
const captured = await dispatchBrowserToDevice(route.device, withoutOutput, 'capture');
|
|
1665
|
-
if (captured.code !== 0) {
|
|
1666
|
-
process.stderr.write(captured.stderr);
|
|
1667
|
-
process.exit(captured.code);
|
|
1668
|
-
}
|
|
1669
|
-
const remotePath = captured.stdout
|
|
1670
|
-
.split('\n')
|
|
1671
|
-
.map((line) => line.trim())
|
|
1672
|
-
.find((line) => line.length > 0);
|
|
1673
|
-
if (!remotePath) {
|
|
1674
|
-
console.error(`Remote screenshot on ${route.device} produced no path.`);
|
|
1675
|
-
process.exit(1);
|
|
1676
|
-
}
|
|
1677
|
-
try {
|
|
1678
|
-
await pullRemoteFile(route.device, remotePath, outputPath);
|
|
1679
|
-
}
|
|
1680
|
-
catch (err) {
|
|
1681
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1682
|
-
process.exit(1);
|
|
1683
|
-
}
|
|
1684
|
-
const saved = path.resolve(outputPath);
|
|
1685
|
-
console.log(saved);
|
|
1686
|
-
process.exit(0);
|
|
1687
|
-
}
|
|
1688
|
-
const result = await dispatchBrowserToDevice(route.device, forwarded, 'stream');
|
|
1689
|
-
if (result.code === 0 && (top === 'done' || top === 'stop') && route.task) {
|
|
1690
|
-
unbindTask(route.task);
|
|
1691
|
-
}
|
|
1692
|
-
process.exit(result.code);
|
|
1693
|
-
});
|
|
1694
|
-
browser
|
|
1695
|
-
.command('remote-control [state]')
|
|
1696
|
-
.description("Allow or deny other fleet machines driving THIS machine's browser over `browser --device` or `agents ssh …` (`agents browser` / `ag browser` / standalone `browser`). " +
|
|
1697
|
-
'`on`/`off` to set (device-local, never synced); no argument prints the current value. Default off.')
|
|
1698
|
-
.option('--json', 'Output as JSON')
|
|
1699
|
-
.action((state, opts) => {
|
|
1700
|
-
const KEY = 'browser.remote-control';
|
|
1701
|
-
if (state === undefined) {
|
|
1702
|
-
const cur = getConfigValue(KEY).value === true;
|
|
1703
|
-
if (opts.json) {
|
|
1704
|
-
console.log(JSON.stringify({ remoteControl: cur }));
|
|
1705
|
-
return;
|
|
1706
|
-
}
|
|
1707
|
-
console.log(`Remote browser control (this machine): ${cur ? 'on' : 'off'}`);
|
|
1708
|
-
if (!cur)
|
|
1709
|
-
console.log('Enable with: agents browser remote-control on');
|
|
1710
|
-
return;
|
|
1711
|
-
}
|
|
1712
|
-
const norm = state.toLowerCase();
|
|
1713
|
-
const onWords = ['on', 'true', 'yes', 'allow', 'enable'];
|
|
1714
|
-
const offWords = ['off', 'false', 'no', 'deny', 'disable'];
|
|
1715
|
-
if (!onWords.includes(norm) && !offWords.includes(norm)) {
|
|
1716
|
-
console.error(`Expected "on" or "off", got "${state}".`);
|
|
1717
|
-
process.exit(1);
|
|
1718
|
-
}
|
|
1719
|
-
const value = onWords.includes(norm);
|
|
1720
|
-
setConfigValue(KEY, value);
|
|
1721
|
-
if (opts.json) {
|
|
1722
|
-
console.log(JSON.stringify({ remoteControl: value }));
|
|
1723
|
-
return;
|
|
1724
|
-
}
|
|
1725
|
-
console.log(`Remote browser control (this machine) is now ${value ? 'on' : 'off'}.`);
|
|
1726
|
-
console.log(value
|
|
1727
|
-
? 'Other fleet machines can now drive this browser via `browser --device <this-device>`.'
|
|
1728
|
-
: 'Cross-machine `browser --device` drives to this machine are refused.');
|
|
1729
|
-
});
|
|
1730
|
-
const stream = browser
|
|
1731
|
-
.command('stream')
|
|
1732
|
-
.description('Keep one process and daemon IPC socket open; read NDJSON requests from stdin and write NDJSON responses')
|
|
1733
|
-
.option(TASK_OPTION_FLAG, 'Default task for requests that omit `task` (defaults to $AGENTS_BROWSER_TASK)')
|
|
1734
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
1735
|
-
.action(async (opts) => {
|
|
1736
|
-
await runBrowserIPCStream({
|
|
1737
|
-
input: process.stdin,
|
|
1738
|
-
output: process.stdout,
|
|
1739
|
-
task: opts.task ?? process.env.AGENTS_BROWSER_TASK,
|
|
1740
|
-
actor: resolveActor().id,
|
|
1741
|
-
launchId: process.env.AGENT_LAUNCH_ID,
|
|
1742
|
-
sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
|
|
1743
|
-
});
|
|
1744
|
-
});
|
|
1745
|
-
setHelpSections(stream, {
|
|
1746
|
-
examples: `
|
|
1747
|
-
# Batch two warm actions through one process and one browser-service connection
|
|
1748
|
-
printf '%s\\n' \\
|
|
1749
|
-
'{"action":"screenshot","path":"/tmp/page.jpg"}' \\
|
|
1750
|
-
'{"action":"click","atX":320,"atY":540}' \\
|
|
1751
|
-
| agents browser stream --task "$AGENTS_BROWSER_TASK"
|
|
228
|
+
Another fleet machine may drive this browser only after \`agents browser
|
|
229
|
+
remote-control on\` here (device-local, never synced; default off).
|
|
1752
230
|
|
|
1753
|
-
|
|
1754
|
-
agents
|
|
1755
|
-
`,
|
|
1756
|
-
notes: `
|
|
1757
|
-
stdout is protocol-only: one compact JSON response for each non-empty input line.
|
|
1758
|
-
Malformed JSON returns an error response without closing the stream.
|
|
1759
|
-
The first start response becomes the default task for later lines in the same stream.
|
|
231
|
+
\`agents browser sessions\` (and \`agents sessions --browser\`) reads the capture
|
|
232
|
+
and task history agents-cli records — it never leaves this CLI.
|
|
1760
233
|
`,
|
|
1761
234
|
});
|
|
1762
|
-
browser
|
|
1763
|
-
.command('start')
|
|
1764
|
-
.description('Start a browser task. Pass --profile <name>; omit to use your configured default (set it with `agents browser use <name>` or `agents setup`). Page verbs (navigate/screenshot/…) create a task implicitly when none exists — start is for --profile/--url/--record/--title.')
|
|
1765
|
-
.option('-p, --profile <name>', 'Browser profile to use (omit to use this machine\'s configured default; set it with `agents browser use <name>`)')
|
|
1766
|
-
.option(TASK_OPTION_FLAG, 'Task name (auto-generated short id if omitted)')
|
|
1767
|
-
.option('--title <label>', 'Human label shown in `browser status` (defaults to first navigated host)')
|
|
1768
|
-
.option('-e, --endpoint <name>', 'Endpoint preset (defaults to the profile\'s default)')
|
|
1769
|
-
.option('-u, --url <url>', 'Open URL in first tab')
|
|
1770
|
-
.option('--device <name>', 'Device that hosts this task (defaults to the browser.device hub when set; use `local` to force this machine). Later verbs resolve it from --task; not valid on page verbs')
|
|
1771
|
-
.option('--fresh', 'Always open a new tab, skipping the reclaim of a tab an abandoned task is holding on that URL')
|
|
1772
|
-
.option('--no-skills', 'Skip auto-discovery of site-specific SKILL.md from ~/.agents/skills/browser/domain-skills/')
|
|
1773
|
-
.option('--record', 'Start recording right after the tab opens (shorthand for `agents browser record start` as a follow-up)')
|
|
1774
|
-
.option('--fps <n>', 'Recording frames per second (with --record; 1–30, default 5)', (v) => parseInt(v, 10))
|
|
1775
|
-
.option('--duration <sec>', 'Recording duration cap in seconds (with --record; default 60)', (v) => parseInt(v, 10))
|
|
1776
|
-
.option('--max-mb <mb>', 'Recording size cap in MB (with --record; default 25)', (v) => parseInt(v, 10))
|
|
1777
|
-
.option('--json', 'Output machine-readable JSON (task handle + tabId + reused/message)')
|
|
1778
|
-
.action(async (opts) => {
|
|
1779
|
-
// Fast-fail copy of the consent gate, so a refused start never resolves or
|
|
1780
|
-
// auto-creates a profile. The AUTHORITATIVE gate is in the daemon
|
|
1781
|
-
// (BrowserService.start / resolveOrCreateTask, via
|
|
1782
|
-
// assertRemoteControlAllowedForRequest) — it has to be, because the page
|
|
1783
|
-
// verbs create a browser implicitly and never reach this command.
|
|
1784
|
-
try {
|
|
1785
|
-
assertRemoteControlAllowed();
|
|
1786
|
-
}
|
|
1787
|
-
catch (err) {
|
|
1788
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1789
|
-
process.exit(1);
|
|
1790
|
-
}
|
|
1791
|
-
// Thin-client hub (PHNX-2010): when this box has a fleet browser hub
|
|
1792
|
-
// (`browser.device`) and the caller gave no explicit --device, forward the
|
|
1793
|
-
// WHOLE start there — bare OR --profile — and skip the local profile/browser
|
|
1794
|
-
// resolution below. The hub owns its profiles, browser, and consent, so this
|
|
1795
|
-
// box needs no local browser and need not carry the named profile; forwarding
|
|
1796
|
-
// the local pre-checks here would reject a profile that lives only on the hub.
|
|
1797
|
-
// Later page verbs follow the task to the hub via the task→device index bound
|
|
1798
|
-
// here. `--device local`/`--device self` (below) force a local run instead;
|
|
1799
|
-
// `defaultBrowserHub()` is undefined on the hub itself and on a fleet-remote
|
|
1800
|
-
// re-exec, so neither forwards to itself.
|
|
1801
|
-
if (!opts.device) {
|
|
1802
|
-
const hub = defaultBrowserHub();
|
|
1803
|
-
if (hub) {
|
|
1804
|
-
// No local profile validation: the hub is the authority on its own
|
|
1805
|
-
// profiles (including fleet-scoped ones this box can't see in
|
|
1806
|
-
// `declaringDevices`), so a bad `--profile` is caught by the hub's own
|
|
1807
|
-
// start and streamed back with its exit code, rather than second-guessed
|
|
1808
|
-
// here against a registry that doesn't carry the hub's profiles.
|
|
1809
|
-
try {
|
|
1810
|
-
const result = await dispatchBrowserToDevice(hub, browserForwardedArgv(), 'capture');
|
|
1811
|
-
process.stdout.write(result.stdout);
|
|
1812
|
-
process.stderr.write(result.stderr);
|
|
1813
|
-
if (result.code !== 0)
|
|
1814
|
-
process.exit(result.code);
|
|
1815
|
-
const taskName = remoteStartTaskName(result.stdout, opts.task);
|
|
1816
|
-
if (!taskName) {
|
|
1817
|
-
console.error(`Remote start on ${hub} produced no task name.`);
|
|
1818
|
-
process.exit(1);
|
|
1819
|
-
}
|
|
1820
|
-
bindTask(taskName, {
|
|
1821
|
-
device: hub,
|
|
1822
|
-
// Only what the caller named; the hub's own default (bare start) is
|
|
1823
|
-
// opaque here. Name-based `done`/`stop --task` and the post-stop
|
|
1824
|
-
// unbind GC these regardless.
|
|
1825
|
-
profile: opts.profile,
|
|
1826
|
-
url: opts.url,
|
|
1827
|
-
sessionId: callerSessionId(),
|
|
1828
|
-
launchId: callerLaunchId(),
|
|
1829
|
-
createdAt: Date.now(),
|
|
1830
|
-
});
|
|
1831
|
-
}
|
|
1832
|
-
catch (err) {
|
|
1833
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1834
|
-
process.exit(1);
|
|
1835
|
-
}
|
|
1836
|
-
return;
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
// Resolve an EXPLICIT --device BEFORE any local profile/browser
|
|
1840
|
-
// resolution (PHNX-3289). The task lives on the TARGET device, so its
|
|
1841
|
-
// browser binary and profile must resolve THERE. Resolving locally first
|
|
1842
|
-
// made a browserless box fail with a misleading "No supported browser
|
|
1843
|
-
// found" (bare start) or "Profile <x> not found" (a profile that lives
|
|
1844
|
-
// only on the target) before the start ever routed to --device. This
|
|
1845
|
-
// mirrors the hub path above: the target is the authority on its own
|
|
1846
|
-
// profiles, so forward the whole start and let it validate. `local`/`self`
|
|
1847
|
-
// force a local run; a fleet-remote re-exec is already ON the target and
|
|
1848
|
-
// falls through to the local launch below.
|
|
1849
|
-
const deviceName = opts.device === 'local' || opts.device === 'self' ? undefined : opts.device;
|
|
1850
|
-
if (deviceName) {
|
|
1851
|
-
const lowered = deviceName.toLowerCase();
|
|
1852
|
-
if (lowered === 'all' || lowered === 'auto') {
|
|
1853
|
-
console.error(`--device ${deviceName} is not valid on \`agents browser start\`: a task lives on one device.\n` +
|
|
1854
|
-
`Next: agents browser start --task <name> --device <device>`);
|
|
1855
|
-
process.exit(1);
|
|
1856
|
-
}
|
|
1857
|
-
if (!isSelfHost(deviceName) && !isFleetRemoteInvocation()) {
|
|
1858
|
-
// Resolve the profile NAME (never a local browser auto-pick) so a
|
|
1859
|
-
// named profile can still be validated against the target's
|
|
1860
|
-
// declarations before we round-trip. A bare start carries no name to
|
|
1861
|
-
// validate — the target picks its own default — so it forwards
|
|
1862
|
-
// straight through, which is exactly what unblocks a browserless box.
|
|
1863
|
-
// Only resolve when the caller actually named a profile: passing an
|
|
1864
|
-
// undefined ref would let `resolveProfileRef` fall back to THIS box's
|
|
1865
|
-
// configured/auto-detected default (matching the ~4 other call sites
|
|
1866
|
-
// in this file), and validating that local name against the target
|
|
1867
|
-
// re-introduces the exact false failure this reorder fixes.
|
|
1868
|
-
const forwardProfile = opts.profile ? await resolveProfileRef(opts.profile) : undefined;
|
|
1869
|
-
if (forwardProfile) {
|
|
1870
|
-
try {
|
|
1871
|
-
assertDeviceDeclaresProfile(deviceName, forwardProfile);
|
|
1872
|
-
}
|
|
1873
|
-
catch (err) {
|
|
1874
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1875
|
-
process.exit(1);
|
|
1876
|
-
}
|
|
1877
|
-
}
|
|
1878
|
-
try {
|
|
1879
|
-
const result = await dispatchBrowserToDevice(deviceName, browserForwardedArgv(), 'capture');
|
|
1880
|
-
process.stdout.write(result.stdout);
|
|
1881
|
-
process.stderr.write(result.stderr);
|
|
1882
|
-
if (result.code !== 0)
|
|
1883
|
-
process.exit(result.code);
|
|
1884
|
-
const taskName = remoteStartTaskName(result.stdout, opts.task);
|
|
1885
|
-
if (!taskName) {
|
|
1886
|
-
console.error(`Remote start on ${deviceName} produced no task name.`);
|
|
1887
|
-
process.exit(1);
|
|
1888
|
-
}
|
|
1889
|
-
bindTask(taskName, {
|
|
1890
|
-
device: deviceName,
|
|
1891
|
-
// Only the caller-named profile; the target's own default (bare
|
|
1892
|
-
// start) is opaque here, exactly as in the hub path.
|
|
1893
|
-
profile: forwardProfile ?? opts.profile,
|
|
1894
|
-
url: opts.url,
|
|
1895
|
-
sessionId: callerSessionId(),
|
|
1896
|
-
launchId: callerLaunchId(),
|
|
1897
|
-
createdAt: Date.now(),
|
|
1898
|
-
});
|
|
1899
|
-
if (!result.stderr.includes(`started on ${deviceName}`)) {
|
|
1900
|
-
console.error(`Task "${taskName}" started on ${deviceName}.`);
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
catch (err) {
|
|
1904
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1905
|
-
process.exit(1);
|
|
1906
|
-
}
|
|
1907
|
-
return;
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
// One resolution order for every command (RUSH-2709): `--profile default`
|
|
1911
|
-
// means the same profile here as it does in stop / status / navigate.
|
|
1912
|
-
// `start` is the one command that LAUNCHES, so its implicit path goes
|
|
1913
|
-
// through ensureDefaultBrowserProfile, which additionally verifies the
|
|
1914
|
-
// resolved default can launch on THIS machine and, when nothing launchable
|
|
1915
|
-
// resolves, throws an actionable "pick a browser in `agents setup`" error
|
|
1916
|
-
// (PHNX-3296 — it no longer silently mints an auto-chrome). A filter-only
|
|
1917
|
-
// command must not warn about the config, so that check lives only here.
|
|
1918
|
-
let profileName;
|
|
1919
|
-
try {
|
|
1920
|
-
profileName = await resolveProfileRefForStart(opts.profile);
|
|
1921
|
-
}
|
|
1922
|
-
catch (err) {
|
|
1923
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1924
|
-
process.exit(1);
|
|
1925
|
-
}
|
|
1926
|
-
// Pre-check the profile locally so we fail fast with a helpful error
|
|
1927
|
-
// instead of round-tripping a generic "Profile not found" through the daemon.
|
|
1928
|
-
const profile = await getProfile(profileName);
|
|
1929
|
-
if (!profile) {
|
|
1930
|
-
console.error(`Profile "${profileName}" not found.`);
|
|
1931
|
-
const all = await listProfiles();
|
|
1932
|
-
if (all.length > 0) {
|
|
1933
|
-
console.error(`Available profiles: ${all.map((p) => p.name).join(', ')}`);
|
|
1934
|
-
}
|
|
1935
|
-
console.error(`Create one with: agents browser profiles create ${profileName} --browser <chrome|comet|chromium|brave|edge|arc|custom>`);
|
|
1936
|
-
process.exit(1);
|
|
1937
|
-
}
|
|
1938
|
-
// Discovery is read-only for list/show/doctor. Starting is the explicit
|
|
1939
|
-
// adoption point: persist only the agents-cli alias/native identity, never
|
|
1940
|
-
// mutate Arc's own profile or Space data.
|
|
1941
|
-
await persistDiscoveredProfile(profileName);
|
|
1942
|
-
// A native endpoint is not CDP and cannot be tunnelled. Re-exec the
|
|
1943
|
-
// complete command on the declaring owner, then bind its returned task
|
|
1944
|
-
// locally so every later verb follows the same owner from task-index.
|
|
1945
|
-
if (!isFleetRemoteInvocation()) {
|
|
1946
|
-
let routed;
|
|
1947
|
-
try {
|
|
1948
|
-
routed = resolveBrowserTarget(profileName, { endpointName: opts.endpoint });
|
|
1949
|
-
}
|
|
1950
|
-
catch (err) {
|
|
1951
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1952
|
-
process.exit(1);
|
|
1953
|
-
}
|
|
1954
|
-
if (!routed.local && routed.commandDispatch) {
|
|
1955
|
-
const result = await dispatchBrowserToDevice(routed.device, browserForwardedArgv(), 'capture');
|
|
1956
|
-
process.stdout.write(result.stdout);
|
|
1957
|
-
process.stderr.write(result.stderr);
|
|
1958
|
-
if (result.code !== 0)
|
|
1959
|
-
process.exit(result.code);
|
|
1960
|
-
const taskName = remoteStartTaskName(result.stdout, opts.task);
|
|
1961
|
-
if (!taskName) {
|
|
1962
|
-
console.error(`Remote start on ${routed.device} produced no task name.`);
|
|
1963
|
-
process.exit(1);
|
|
1964
|
-
}
|
|
1965
|
-
bindTask(taskName, {
|
|
1966
|
-
device: routed.device,
|
|
1967
|
-
profile: profileName,
|
|
1968
|
-
url: opts.url,
|
|
1969
|
-
sessionId: callerSessionId(),
|
|
1970
|
-
launchId: callerLaunchId(),
|
|
1971
|
-
createdAt: Date.now(),
|
|
1972
|
-
});
|
|
1973
|
-
return;
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
// Pre-check the endpoint name too — same fail-fast rationale.
|
|
1977
|
-
if (opts.endpoint) {
|
|
1978
|
-
const presets = getEndpointPresets(profile);
|
|
1979
|
-
if (!presets[opts.endpoint]) {
|
|
1980
|
-
console.error(`Endpoint "${opts.endpoint}" not found on profile "${profileName}". ` +
|
|
1981
|
-
`Available: ${Object.keys(presets).join(', ')}`);
|
|
1982
|
-
process.exit(1);
|
|
1983
|
-
}
|
|
1984
|
-
}
|
|
1985
|
-
// Only the LOCAL launch reaches here — an explicit remote --device already
|
|
1986
|
-
// forwarded and returned above. A residual `deviceName` means an explicit
|
|
1987
|
-
// self target (or a fleet-remote re-exec on the target); its profile
|
|
1988
|
-
// resolved locally, so verify this box actually declares it (the
|
|
1989
|
-
// `profiles claim` hint lives in `assertDeviceDeclaresProfile`).
|
|
1990
|
-
if (deviceName) {
|
|
1991
|
-
try {
|
|
1992
|
-
assertDeviceDeclaresProfile(deviceName, profileName);
|
|
1993
|
-
}
|
|
1994
|
-
catch (err) {
|
|
1995
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1996
|
-
process.exit(1);
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
// Grounded login guardrail: if the opening URL is a known login-gated
|
|
2000
|
-
// service and the resolved profile has no session for it, say so on stderr
|
|
2001
|
-
// and name a profile that IS logged in. Reads the profile's real cookie DB
|
|
2002
|
-
// (presence only) — never blocks or slows start; any failure is silent.
|
|
2003
|
-
if (opts.url) {
|
|
2004
|
-
try {
|
|
2005
|
-
const service = serviceForUrl(opts.url);
|
|
2006
|
-
if (service) {
|
|
2007
|
-
const here = await loginsForProfile(profileName);
|
|
2008
|
-
if (!here.includes(service)) {
|
|
2009
|
-
const elsewhere = (await profilesLoggedInto(service)).filter((p) => p !== profileName);
|
|
2010
|
-
const hint = elsewhere.length
|
|
2011
|
-
? `logged in elsewhere: ${elsewhere.join(', ')}. try: --profile ${elsewhere[0]}`
|
|
2012
|
-
: `no profile on this machine has a ${service} session.`;
|
|
2013
|
-
console.error(`warning: profile "${profileName}" (${profile.browser}) has no ${service} session. ${hint}`);
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
|
-
}
|
|
2017
|
-
catch { /* login detection is best-effort; never block start */ }
|
|
2018
|
-
}
|
|
2019
|
-
// Identity is stamped inside sendIPCRequest; no need to pass it here.
|
|
2020
|
-
const response = await sendIPCRequest({
|
|
2021
|
-
action: 'start',
|
|
2022
|
-
profile: profileName,
|
|
2023
|
-
taskName: opts.task,
|
|
2024
|
-
url: opts.url,
|
|
2025
|
-
endpoint: opts.endpoint,
|
|
2026
|
-
skipDomainSkill: opts.skills === false,
|
|
2027
|
-
fresh: opts.fresh === true,
|
|
2028
|
-
title: opts.title,
|
|
2029
|
-
});
|
|
2030
|
-
if (!response.ok) {
|
|
2031
|
-
console.error(response.error);
|
|
2032
|
-
process.exit(1);
|
|
2033
|
-
}
|
|
2034
|
-
const boundDevice = deviceName && isSelfHost(deviceName) ? deviceName : machineId();
|
|
2035
|
-
if (response.task) {
|
|
2036
|
-
bindTask(response.task, {
|
|
2037
|
-
device: boundDevice,
|
|
2038
|
-
profile: profileName,
|
|
2039
|
-
url: opts.url,
|
|
2040
|
-
sessionId: callerSessionId(),
|
|
2041
|
-
launchId: callerLaunchId(),
|
|
2042
|
-
createdAt: Date.now(),
|
|
2043
|
-
});
|
|
2044
|
-
}
|
|
2045
|
-
// --json: machine-readable, still task-handle-first for callers that parse it.
|
|
2046
|
-
if (opts.json) {
|
|
2047
|
-
console.log(JSON.stringify({
|
|
2048
|
-
ok: true,
|
|
2049
|
-
task: response.task,
|
|
2050
|
-
tabId: response.tabId,
|
|
2051
|
-
device: boundDevice,
|
|
2052
|
-
profile: profileName,
|
|
2053
|
-
// `reused` is task-level; `created`/`refreshed` describe the page op.
|
|
2054
|
-
reused: response.reused === true,
|
|
2055
|
-
created: response.created,
|
|
2056
|
-
refreshed: response.refreshed,
|
|
2057
|
-
message: response.message,
|
|
2058
|
-
}, null, 2));
|
|
2059
|
-
return;
|
|
2060
|
-
}
|
|
2061
|
-
// stdout: just the resolved name, one line, no decoration. Lets callers do:
|
|
2062
|
-
// export AGENTS_BROWSER_TASK=$(agents browser start --profile work)
|
|
2063
|
-
console.log(response.task);
|
|
2064
|
-
// stderr: human-friendly commentary so a TTY user still sees what happened.
|
|
2065
|
-
// Shell substitution captures stdout only, so $(...) stays clean. A same-name
|
|
2066
|
-
// retry reused the TASK — key on `reused`, not the page-level `refreshed`
|
|
2067
|
-
// (a no-URL retry reuses the task without reloading anything).
|
|
2068
|
-
const startVerb = response.reused ? 'reused' : 'started';
|
|
2069
|
-
console.error(`Task "${response.task}" ${startVerb} on ${boundDevice} (profile: ${profileName}).`);
|
|
2070
|
-
if (response.message)
|
|
2071
|
-
console.error(response.message);
|
|
2072
|
-
if (opts.url && response.tabId) {
|
|
2073
|
-
console.error(`Tab ${response.tabId}`);
|
|
2074
|
-
}
|
|
2075
|
-
console.error('Tip: page verbs resolve this task from --task; --device is only valid on start.');
|
|
2076
|
-
console.error('Try: agents browser screenshot | agents browser console --level error');
|
|
2077
|
-
// Surface the matched domain-skill (if any) so an agent driving the
|
|
2078
|
-
// task picks up site-specific selectors and gotchas before it starts
|
|
2079
|
-
// clicking. Header is recognizable so an agent parsing the stream can
|
|
2080
|
-
// extract the skill content; suffix repeats the skill name for greps.
|
|
2081
|
-
if (response.skill) {
|
|
2082
|
-
console.error('');
|
|
2083
|
-
console.error(`--- domain-skill: ${response.skill.name} (${response.skill.hostname}) ---`);
|
|
2084
|
-
console.error(response.skill.content);
|
|
2085
|
-
console.error(`--- end domain-skill: ${response.skill.name} ---`);
|
|
2086
|
-
}
|
|
2087
|
-
// --record convenience: fire record-start right after the tab opens so
|
|
2088
|
-
// the user gets a single-command capture flow. Failures here are
|
|
2089
|
-
// reported but don't fail the start — the task is already running.
|
|
2090
|
-
if (opts.record) {
|
|
2091
|
-
const recordResponse = await sendIPCRequest({
|
|
2092
|
-
action: 'record-start',
|
|
2093
|
-
task: response.task,
|
|
2094
|
-
tabId: response.tabId,
|
|
2095
|
-
fps: opts.fps,
|
|
2096
|
-
duration: opts.duration,
|
|
2097
|
-
maxMb: opts.maxMb,
|
|
2098
|
-
});
|
|
2099
|
-
if (!recordResponse.ok) {
|
|
2100
|
-
console.error(`Recording failed to start: ${recordResponse.error}`);
|
|
2101
|
-
}
|
|
2102
|
-
else {
|
|
2103
|
-
console.error(`Recording at ${recordResponse.fps} fps (cap ${recordResponse.durationCapSec}s / ${recordResponse.maxMb} MB) -> ${recordResponse.path}`);
|
|
2104
|
-
console.error('Stop with: agents browser record stop');
|
|
2105
|
-
}
|
|
2106
|
-
}
|
|
2107
|
-
});
|
|
2108
|
-
browser
|
|
2109
|
-
.command('done')
|
|
2110
|
-
.description('Complete a task and close its tabs (resolves from caller identity when --task is omitted)')
|
|
2111
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2112
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2113
|
-
.action(async (opts) => {
|
|
2114
|
-
const task = resolveTaskName(opts);
|
|
2115
|
-
const response = await sendIPCRequest({
|
|
2116
|
-
action: 'done',
|
|
2117
|
-
task,
|
|
2118
|
-
});
|
|
2119
|
-
if (!response.ok) {
|
|
2120
|
-
console.error(response.error);
|
|
2121
|
-
process.exit(1);
|
|
2122
|
-
}
|
|
2123
|
-
if (response.message === 'nothing to close' || !response.task) {
|
|
2124
|
-
console.log('nothing to close');
|
|
2125
|
-
return;
|
|
2126
|
-
}
|
|
2127
|
-
console.log(`Completed task: ${response.task}`);
|
|
2128
|
-
});
|
|
2129
|
-
browser
|
|
2130
|
-
.command('stop')
|
|
2131
|
-
.description('Stop a browser task and close its tabs; with --profile, detach the whole profile; with --service, stop only browser IPC while the shared daemon stays up')
|
|
2132
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2133
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2134
|
-
.option('-p, --profile <name>', 'Detach the whole profile (incl. composite "name@endpoint") instead of stopping a single task')
|
|
2135
|
-
.option('--service', 'Stop only the browser-ipc service and clear a stale/wedged socket; the shared daemon and its other services stay up')
|
|
2136
|
-
.addOption(new Option('--daemon').hideHelp())
|
|
2137
|
-
.action(async (opts) => {
|
|
2138
|
-
if (opts.service || opts.daemon) {
|
|
2139
|
-
if (opts.profile || opts.task) {
|
|
2140
|
-
console.error('--service stops browser IPC; do not combine it with --profile or --task.');
|
|
2141
|
-
process.exit(1);
|
|
2142
|
-
}
|
|
2143
|
-
if (opts.daemon) {
|
|
2144
|
-
console.error('`--daemon` now means the browser service only; use `--service`. The shared daemon will not be stopped.');
|
|
2145
|
-
}
|
|
2146
|
-
try {
|
|
2147
|
-
const result = await stopBrowserService();
|
|
2148
|
-
const parts = [];
|
|
2149
|
-
parts.push(result.wasRunning ? 'Stopped browser service' : 'Browser service was not running');
|
|
2150
|
-
if (result.socketCleared)
|
|
2151
|
-
parts.push('cleared stale socket');
|
|
2152
|
-
parts.push(result.daemonRunning ? 'shared daemon is still running' : 'shared daemon was already stopped');
|
|
2153
|
-
console.log(`${parts.join('; ')}. Browser IPC starts again on the next browser action that requires it.`);
|
|
2154
|
-
}
|
|
2155
|
-
catch (err) {
|
|
2156
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
2157
|
-
process.exit(1);
|
|
2158
|
-
}
|
|
2159
|
-
return;
|
|
2160
|
-
}
|
|
2161
|
-
if (opts.profile) {
|
|
2162
|
-
const profile = (await resolveProfileRef(opts.profile)) ?? opts.profile;
|
|
2163
|
-
const response = await sendIPCRequest({
|
|
2164
|
-
action: 'stop',
|
|
2165
|
-
profile,
|
|
2166
|
-
});
|
|
2167
|
-
if (!response.ok) {
|
|
2168
|
-
console.error(response.error);
|
|
2169
|
-
process.exit(1);
|
|
2170
|
-
}
|
|
2171
|
-
console.log(`Stopped profile: ${profile}`);
|
|
2172
|
-
return;
|
|
2173
|
-
}
|
|
2174
|
-
const task = resolveTaskName(opts);
|
|
2175
|
-
const response = await sendIPCRequest({
|
|
2176
|
-
action: 'stop',
|
|
2177
|
-
task,
|
|
2178
|
-
});
|
|
2179
|
-
if (!response.ok) {
|
|
2180
|
-
console.error(response.error);
|
|
2181
|
-
process.exit(1);
|
|
2182
|
-
}
|
|
2183
|
-
if (response.message === 'nothing to close' || !response.task) {
|
|
2184
|
-
console.log('nothing to close');
|
|
2185
|
-
return;
|
|
2186
|
-
}
|
|
2187
|
-
console.log(`Stopped task: ${response.task}`);
|
|
2188
|
-
});
|
|
2189
|
-
browser
|
|
2190
|
-
.command('prune')
|
|
2191
|
-
.alias('gc')
|
|
2192
|
-
.description('Close tabs for abandoned tasks — owning agent session exited, or idle past the window — and mark them done. ' +
|
|
2193
|
-
'The same reaper the daemon already runs every 5 minutes; use this to run it now.')
|
|
2194
|
-
.option('--dry-run', 'List what would be closed without closing anything')
|
|
2195
|
-
.option('--idle-minutes <n>', 'Override the idle window in minutes (default: this device\'s browser.task-idle-minutes, or 30). ' +
|
|
2196
|
-
'0 disables idle-only closing for this run; session-dead tasks are still closed.', (v) => parseInt(v, 10))
|
|
2197
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2198
|
-
.action(async (opts) => {
|
|
2199
|
-
const response = await sendIPCRequest({
|
|
2200
|
-
action: 'gc',
|
|
2201
|
-
dryRun: opts.dryRun,
|
|
2202
|
-
idleMinutes: opts.idleMinutes,
|
|
2203
|
-
});
|
|
2204
|
-
if (!response.ok) {
|
|
2205
|
-
if (opts.json) {
|
|
2206
|
-
console.log(JSON.stringify({ ok: false, error: response.error }));
|
|
2207
|
-
}
|
|
2208
|
-
else {
|
|
2209
|
-
console.error(response.error);
|
|
2210
|
-
}
|
|
2211
|
-
process.exit(1);
|
|
2212
|
-
}
|
|
2213
|
-
const reaped = response.reaped ?? { closed: [], skipped: 0 };
|
|
2214
|
-
if (opts.json) {
|
|
2215
|
-
console.log(JSON.stringify(reaped, null, 2));
|
|
2216
|
-
return;
|
|
2217
|
-
}
|
|
2218
|
-
if (reaped.closed.length === 0) {
|
|
2219
|
-
console.log(`No abandoned tasks${opts.dryRun ? ' (dry run)' : ''}; ${reaped.skipped} task(s) still active.`);
|
|
2220
|
-
return;
|
|
2221
|
-
}
|
|
2222
|
-
const verb = opts.dryRun ? 'Would close' : 'Closed';
|
|
2223
|
-
console.log(`${verb} ${reaped.closed.length} task(s), left ${reaped.skipped} alone:`);
|
|
2224
|
-
for (const c of reaped.closed) {
|
|
2225
|
-
console.log(` ${c.task} (${c.profile}, ${c.reason})`);
|
|
2226
|
-
}
|
|
2227
|
-
});
|
|
2228
|
-
browser
|
|
2229
|
-
.command('show <url>')
|
|
2230
|
-
.description('Open a URL or local file for a human to read: goes to browser.viewer (default: browser.profile), and binds no task')
|
|
2231
|
-
.option('--os-browser', 'Use the OS default handler instead of the configured viewer')
|
|
2232
|
-
// `--device` here names WHERE THE FILE IS, not where to open it. `show` is the
|
|
2233
|
-
// one browser verb narrowly exempt from the task-routing refusal
|
|
2234
|
-
// (`REJECT_DEVICE_MESSAGE`) because it binds no task and has nothing to route:
|
|
2235
|
-
// it is a local viewer, so the flag can only mean "the source lives there".
|
|
2236
|
-
// Every other verb still refuses `--device` — a task is bound at `start`.
|
|
2237
|
-
.option('--device <name>', 'fetch the file from this device, then view it locally (absolute path only)')
|
|
2238
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2239
|
-
.action(async (url, opts) => {
|
|
2240
|
-
// The entry point external tools need. `navigate` binds a task, which the
|
|
2241
|
-
// abandoned-task reaper closes when the calling session ends — wrong for a
|
|
2242
|
-
// page a person is reading. This does not.
|
|
2243
|
-
const { showUrl, showFile } = await import('../lib/open-url.js');
|
|
2244
|
-
// `+` not `*` on the scheme body: `[a-z][a-z0-9+.-]*:` matches a Windows
|
|
2245
|
-
// drive letter (`C:\Users\me\plan.html`), so a real path was treated as a
|
|
2246
|
-
// URL. A scheme is at least two characters.
|
|
2247
|
-
const isLocalFile = !/^[a-z][a-z0-9+.-]+:/i.test(url);
|
|
2248
|
-
let localTarget = isLocalFile ? path.resolve(url) : undefined;
|
|
2249
|
-
let fetched;
|
|
2250
|
-
if (opts.device) {
|
|
2251
|
-
// A URL is already reachable from here, so `--device` with one is a
|
|
2252
|
-
// contradiction rather than a no-op: it says the source is remote while
|
|
2253
|
-
// naming something that is not a file on that peer.
|
|
2254
|
-
if (!isLocalFile) {
|
|
2255
|
-
console.error(`--device names the device holding a FILE, so it cannot be combined with a URL.\n Drop --device to open ${url} here, or pass an absolute path on ${opts.device}.`);
|
|
2256
|
-
process.exit(1);
|
|
2257
|
-
}
|
|
2258
|
-
// The peer's path, verbatim — NOT path.resolve()'d, which would rewrite it
|
|
2259
|
-
// against this machine's cwd and name a different file there.
|
|
2260
|
-
const remotePath = url;
|
|
2261
|
-
const dir = path.join(getBrowserRuntimeDir(), 'remote-views');
|
|
2262
|
-
fetched = path.join(dir, `${normalizeHost(opts.device)}-${Date.now()}-${path.basename(remotePath) || 'capture'}`);
|
|
2263
|
-
try {
|
|
2264
|
-
await fetchRemoteFileForViewing(opts.device, remotePath, fetched);
|
|
2265
|
-
}
|
|
2266
|
-
catch (error) {
|
|
2267
|
-
console.error(error.message);
|
|
2268
|
-
process.exit(1);
|
|
2269
|
-
}
|
|
2270
|
-
localTarget = fetched;
|
|
2271
|
-
}
|
|
2272
|
-
const outcome = localTarget
|
|
2273
|
-
? await showFile(localTarget, { osBrowser: opts.osBrowser })
|
|
2274
|
-
: await showUrl(url, { osBrowser: opts.osBrowser });
|
|
2275
|
-
// The fetched copy is what a viewer actually opened, so it is what the
|
|
2276
|
-
// caller is told about — pointing at the peer's path would name a file that
|
|
2277
|
-
// is not on this machine.
|
|
2278
|
-
const shown = fetched ?? url;
|
|
2279
|
-
if (opts.json) {
|
|
2280
|
-
console.log(JSON.stringify({ ...outcome, ...(fetched ? { source: `${opts.device}:${url}`, local: fetched } : {}) }, null, 2));
|
|
2281
|
-
}
|
|
2282
|
-
else if (outcome.via === 'none') {
|
|
2283
|
-
console.error(`Could not open a browser — open this yourself:\n ${shown}`);
|
|
2284
|
-
}
|
|
2285
|
-
else if (outcome.via === 'profile') {
|
|
2286
|
-
console.log(`Shown in ${outcome.profile}: ${shown}`);
|
|
2287
|
-
}
|
|
2288
|
-
else {
|
|
2289
|
-
console.log(`Opened in the OS default browser: ${shown}`);
|
|
2290
|
-
}
|
|
2291
|
-
if (outcome.via === 'none')
|
|
2292
|
-
process.exit(1);
|
|
2293
|
-
});
|
|
2294
|
-
browser
|
|
2295
|
-
.command('navigate [url]')
|
|
2296
|
-
.alias('goto')
|
|
2297
|
-
.description('Navigate current tab to URL (creates a task and tab when none exist)')
|
|
2298
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2299
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2300
|
-
.option('-u, --url <url>', 'URL to navigate to (or pass it positionally)')
|
|
2301
|
-
.option('-p, --profile <name>', 'Browser profile (optional if task is unique)')
|
|
2302
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2303
|
-
.action(async (urlPos, opts) => {
|
|
2304
|
-
const url = urlPos || opts.url;
|
|
2305
|
-
if (!url) {
|
|
2306
|
-
console.error('URL required. Usage: agents browser navigate <url>');
|
|
2307
|
-
process.exit(1);
|
|
2308
|
-
}
|
|
2309
|
-
const task = resolveTaskName(opts);
|
|
2310
|
-
const response = await sendIPCRequest({
|
|
2311
|
-
action: 'navigate',
|
|
2312
|
-
task,
|
|
2313
|
-
url,
|
|
2314
|
-
profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
|
|
2315
|
-
});
|
|
2316
|
-
if (!response.ok) {
|
|
2317
|
-
if (opts.json)
|
|
2318
|
-
console.log(JSON.stringify({ ok: false, error: response.error }));
|
|
2319
|
-
else
|
|
2320
|
-
console.error(response.error);
|
|
2321
|
-
process.exit(1);
|
|
2322
|
-
}
|
|
2323
|
-
if (opts.json) {
|
|
2324
|
-
console.log(JSON.stringify({
|
|
2325
|
-
ok: true,
|
|
2326
|
-
task: response.task,
|
|
2327
|
-
tabId: response.tabId,
|
|
2328
|
-
url,
|
|
2329
|
-
created: response.created ?? false,
|
|
2330
|
-
refreshed: response.refreshed ?? false,
|
|
2331
|
-
message: response.message,
|
|
2332
|
-
}, null, 2));
|
|
2333
|
-
return;
|
|
2334
|
-
}
|
|
2335
|
-
if (response.task) {
|
|
2336
|
-
console.error(`task ${response.task}`);
|
|
2337
|
-
}
|
|
2338
|
-
// Honest human output: a same-task reopen reloaded the SAME tab rather than
|
|
2339
|
-
// navigating a fresh one (PHNX-2399). Any picked-device / reopen note rides
|
|
2340
|
-
// stderr so stdout stays the one-line result.
|
|
2341
|
-
if (response.message)
|
|
2342
|
-
console.error(response.message);
|
|
2343
|
-
const verb = response.refreshed ? 'Refreshed' : 'Navigated';
|
|
2344
|
-
console.log(`${verb} ${response.tabId} to ${url}`);
|
|
2345
|
-
});
|
|
2346
|
-
// Tab subcommand group
|
|
2347
|
-
const tab = browser.command('tab').description('Manage tabs');
|
|
2348
|
-
tab
|
|
2349
|
-
.command('add')
|
|
2350
|
-
.description('Open URL in new tab (becomes current)')
|
|
2351
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2352
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2353
|
-
.requiredOption('--url <url>', 'URL to open in the new tab')
|
|
2354
|
-
.option('-p, --profile <name>', 'Browser profile')
|
|
2355
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2356
|
-
.action(async (opts) => {
|
|
2357
|
-
const task = resolveTaskName(opts);
|
|
2358
|
-
const response = await sendIPCRequest({
|
|
2359
|
-
action: 'tab-add',
|
|
2360
|
-
task,
|
|
2361
|
-
url: opts.url,
|
|
2362
|
-
profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
|
|
2363
|
-
});
|
|
2364
|
-
if (!response.ok) {
|
|
2365
|
-
if (opts.json)
|
|
2366
|
-
console.log(JSON.stringify({ ok: false, error: response.error }));
|
|
2367
|
-
else
|
|
2368
|
-
console.error(response.error);
|
|
2369
|
-
process.exit(1);
|
|
2370
|
-
}
|
|
2371
|
-
if (opts.json) {
|
|
2372
|
-
console.log(JSON.stringify({
|
|
2373
|
-
ok: true,
|
|
2374
|
-
task: response.task,
|
|
2375
|
-
tabId: response.tabId,
|
|
2376
|
-
url: opts.url,
|
|
2377
|
-
created: response.created ?? false,
|
|
2378
|
-
refreshed: response.refreshed ?? false,
|
|
2379
|
-
message: response.message,
|
|
2380
|
-
}, null, 2));
|
|
2381
|
-
return;
|
|
2382
|
-
}
|
|
2383
|
-
// A reopen of a URL already in an owned tab refreshes that tab in place
|
|
2384
|
-
// rather than opening a duplicate (PHNX-2399).
|
|
2385
|
-
if (response.refreshed) {
|
|
2386
|
-
if (response.message)
|
|
2387
|
-
console.error(response.message);
|
|
2388
|
-
console.log(`Refreshed tab ${response.tabId}: ${opts.url}`);
|
|
2389
|
-
}
|
|
2390
|
-
else {
|
|
2391
|
-
console.log(`Opened tab ${response.tabId}: ${opts.url}`);
|
|
2392
|
-
}
|
|
2393
|
-
});
|
|
2394
|
-
tab
|
|
2395
|
-
.command('focus <tabId>')
|
|
2396
|
-
.description('Switch to tab (by ID, prefix, or URL substring)')
|
|
2397
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2398
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2399
|
-
.action(async (tabId, opts) => {
|
|
2400
|
-
const task = resolveTaskName(opts);
|
|
2401
|
-
const response = await sendIPCRequest({
|
|
2402
|
-
action: 'tab-focus',
|
|
2403
|
-
task,
|
|
2404
|
-
tabId,
|
|
2405
|
-
});
|
|
2406
|
-
if (!response.ok) {
|
|
2407
|
-
console.error(response.error);
|
|
2408
|
-
process.exit(1);
|
|
2409
|
-
}
|
|
2410
|
-
console.log(`Focused tab ${response.tabId}`);
|
|
2411
|
-
});
|
|
2412
|
-
tab
|
|
2413
|
-
.command('close [tabId]')
|
|
2414
|
-
.description('Close tab(s) — omit tabId to close all')
|
|
2415
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2416
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2417
|
-
.action(async (tabId, opts) => {
|
|
2418
|
-
const task = resolveTaskName(opts);
|
|
2419
|
-
const response = await sendIPCRequest({
|
|
2420
|
-
action: 'tab-close',
|
|
2421
|
-
task,
|
|
2422
|
-
tabId,
|
|
2423
|
-
});
|
|
2424
|
-
if (!response.ok) {
|
|
2425
|
-
console.error(response.error);
|
|
2426
|
-
process.exit(1);
|
|
2427
|
-
}
|
|
2428
|
-
console.log(tabId ? `Closed tab ${tabId}` : `Closed all tabs for ${task}`);
|
|
2429
|
-
});
|
|
2430
|
-
browser
|
|
2431
|
-
.command('tabs')
|
|
2432
|
-
.description('List tabs open for the current task; --all shows every tab open in the profile browser, yours included')
|
|
2433
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2434
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2435
|
-
.option('--all', "Every tab open in the profile browser, not only this task's: OWNER names the owning task, or \"you\" for a tab no task owns")
|
|
2436
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2437
|
-
.action(async (opts) => {
|
|
2438
|
-
const task = resolveTaskName(opts);
|
|
2439
|
-
const response = await sendIPCRequest({
|
|
2440
|
-
action: 'tab-list',
|
|
2441
|
-
task,
|
|
2442
|
-
...(opts.all ? { all: true } : {}),
|
|
2443
|
-
});
|
|
2444
|
-
if (!response.ok) {
|
|
2445
|
-
if (opts.json) {
|
|
2446
|
-
console.log(JSON.stringify({ ok: false, error: response.error }));
|
|
2447
|
-
}
|
|
2448
|
-
else {
|
|
2449
|
-
console.error(response.error);
|
|
2450
|
-
}
|
|
2451
|
-
process.exit(1);
|
|
2452
|
-
}
|
|
2453
|
-
if (opts.json) {
|
|
2454
|
-
console.log(JSON.stringify(response.tabs ?? [], null, 2));
|
|
2455
|
-
return;
|
|
2456
|
-
}
|
|
2457
|
-
if (!response.tabs || response.tabs.length === 0) {
|
|
2458
|
-
console.log('No tabs open');
|
|
2459
|
-
return;
|
|
2460
|
-
}
|
|
2461
|
-
if (opts.all) {
|
|
2462
|
-
console.log('TAB'.padEnd(12) + 'OWNER'.padEnd(18) + 'URL');
|
|
2463
|
-
console.log('-'.repeat(88));
|
|
2464
|
-
for (const t of response.tabs) {
|
|
2465
|
-
const owner = t.task ?? 'you';
|
|
2466
|
-
const current = t.current ? ' *' : '';
|
|
2467
|
-
console.log(t.id.slice(0, 11).padEnd(12) + owner.slice(0, 17).padEnd(18) + t.url.slice(0, 55) + current);
|
|
2468
|
-
}
|
|
2469
|
-
return;
|
|
2470
|
-
}
|
|
2471
|
-
console.log('TAB'.padEnd(12) + 'URL');
|
|
2472
|
-
console.log('-'.repeat(70));
|
|
2473
|
-
for (const t of response.tabs) {
|
|
2474
|
-
const current = t.current ? ' *' : '';
|
|
2475
|
-
console.log(t.id.padEnd(12) + t.url.slice(0, 55) + current);
|
|
2476
|
-
}
|
|
2477
|
-
});
|
|
2478
|
-
browser
|
|
2479
|
-
.command('screenshot')
|
|
2480
|
-
.description('Take a screenshot — auto-saved per task; --output only needed when you want a specific path')
|
|
2481
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2482
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2483
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
2484
|
-
.option('-o, --output <path>', 'Specific output path (otherwise auto-saved under sessions/<task>/)')
|
|
2485
|
-
.option('-q, --quality <mode>', 'compressed (JPEG, capped at ~100 KB — default) or raw (PNG, pixel-faithful)', 'compressed')
|
|
2486
|
-
.action(async (opts) => {
|
|
2487
|
-
const task = resolveTaskName(opts);
|
|
2488
|
-
if (opts.quality !== 'compressed' && opts.quality !== 'raw') {
|
|
2489
|
-
console.error('--quality must be "compressed" or "raw"');
|
|
2490
|
-
process.exit(1);
|
|
2491
|
-
}
|
|
2492
|
-
const response = await sendIPCRequest({
|
|
2493
|
-
action: 'screenshot',
|
|
2494
|
-
task,
|
|
2495
|
-
tabId: opts.tab,
|
|
2496
|
-
path: opts.output,
|
|
2497
|
-
quality: opts.quality,
|
|
2498
|
-
});
|
|
2499
|
-
if (!response.ok) {
|
|
2500
|
-
console.error(response.error);
|
|
2501
|
-
process.exit(1);
|
|
2502
|
-
}
|
|
2503
|
-
// RUSH-3086: the daemon sandboxes writes to the runtime dir and silently
|
|
2504
|
-
// ignores `-o` outside it. Honor the requested path here.
|
|
2505
|
-
const savedPath = response.path
|
|
2506
|
-
? honorScreenshotOutput(opts.output, response.path)
|
|
2507
|
-
: response.path;
|
|
2508
|
-
// stdout: just the path, so `P=$(agents browser screenshot)` works.
|
|
2509
|
-
console.log(savedPath);
|
|
2510
|
-
// stderr: human commentary with size + dimensions, so an agent can
|
|
2511
|
-
// see at a glance what was captured without `ls -l && file` round-trips.
|
|
2512
|
-
const size = humanizeBytes(response.bytes);
|
|
2513
|
-
const dims = response.width && response.height ? `${response.width}×${response.height}` : 'unknown size';
|
|
2514
|
-
console.error(`Saved screenshot to ${savedPath} (${size}, ${dims})`);
|
|
2515
|
-
// When auto-saving (no --output), surface the directory once so the
|
|
2516
|
-
// agent doesn't have to dirname() the path or guess where files land.
|
|
2517
|
-
if (!opts.output && savedPath) {
|
|
2518
|
-
const dir = path.dirname(savedPath);
|
|
2519
|
-
console.error(`Tip: auto-saving to ${dir}. Pass --output <path> to choose a path.`);
|
|
2520
|
-
}
|
|
2521
|
-
});
|
|
2522
|
-
browser
|
|
2523
|
-
.command('pdf [output]')
|
|
2524
|
-
.description('Export the current tab as PDF via CDP Page.printToPDF — auto-saved under sessions/<task>/ when [output] is omitted')
|
|
2525
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2526
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2527
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
2528
|
-
.action(async (output, opts) => {
|
|
2529
|
-
const task = resolveTaskName(opts);
|
|
2530
|
-
const response = await sendIPCRequest({
|
|
2531
|
-
action: 'pdf',
|
|
2532
|
-
task,
|
|
2533
|
-
tabId: opts.tab,
|
|
2534
|
-
path: output,
|
|
2535
|
-
});
|
|
2536
|
-
if (!response.ok) {
|
|
2537
|
-
console.error(response.error);
|
|
2538
|
-
process.exit(1);
|
|
2539
|
-
}
|
|
2540
|
-
console.log(response.path);
|
|
2541
|
-
const size = humanizeBytes(response.bytes);
|
|
2542
|
-
console.error(`Saved PDF to ${response.path} (${size})`);
|
|
2543
|
-
if (!output && response.path) {
|
|
2544
|
-
const dir = path.dirname(response.path);
|
|
2545
|
-
console.error(`Tip: auto-saving to ${dir}. Pass a path argument to choose one.`);
|
|
2546
|
-
}
|
|
2547
|
-
});
|
|
2548
|
-
browser
|
|
2549
|
-
.command('evaluate [expression]')
|
|
2550
|
-
.alias('eval')
|
|
2551
|
-
.description('Evaluate JavaScript in current tab')
|
|
2552
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2553
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2554
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
2555
|
-
.option('-e, --expression <js>', 'JavaScript expression to evaluate (or pass it positionally)')
|
|
2556
|
-
.option('-f, --file <path>', 'Path to a .js file whose contents will be evaluated')
|
|
2557
|
-
.action(async (exprPos, opts) => {
|
|
2558
|
-
const task = resolveTaskName(opts);
|
|
2559
|
-
const flagExpr = opts.expression || exprPos;
|
|
2560
|
-
if (flagExpr && opts.file) {
|
|
2561
|
-
console.error('Pass exactly one of an expression or --file');
|
|
2562
|
-
process.exit(1);
|
|
2563
|
-
}
|
|
2564
|
-
let expression;
|
|
2565
|
-
if (opts.file) {
|
|
2566
|
-
try {
|
|
2567
|
-
expression = fs.readFileSync(opts.file, 'utf8');
|
|
2568
|
-
}
|
|
2569
|
-
catch (err) {
|
|
2570
|
-
console.error(`Cannot read --file ${opts.file}: ${err.message}`);
|
|
2571
|
-
process.exit(1);
|
|
2572
|
-
}
|
|
2573
|
-
}
|
|
2574
|
-
else if (flagExpr) {
|
|
2575
|
-
expression = flagExpr;
|
|
2576
|
-
}
|
|
2577
|
-
else {
|
|
2578
|
-
console.error('Pass an expression positionally, -e <js>, or --file <path>');
|
|
2579
|
-
process.exit(1);
|
|
2580
|
-
}
|
|
2581
|
-
const response = await sendIPCRequest({
|
|
2582
|
-
action: 'evaluate',
|
|
2583
|
-
task,
|
|
2584
|
-
tabId: opts.tab,
|
|
2585
|
-
expr: expression,
|
|
2586
|
-
});
|
|
2587
|
-
if (!response.ok) {
|
|
2588
|
-
console.error(response.error);
|
|
2589
|
-
process.exit(1);
|
|
2590
|
-
}
|
|
2591
|
-
console.log(JSON.stringify(response.result, null, 2));
|
|
2592
|
-
});
|
|
2593
|
-
browser
|
|
2594
|
-
.command('ps')
|
|
2595
|
-
.description('List every browser/electron/tunnel process agents has tracked (alive or stale) — works without the daemon')
|
|
2596
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2597
|
-
.action((opts) => {
|
|
2598
|
-
const snapshots = listAllProfileSnapshots();
|
|
2599
|
-
// Cross-check against what's actually listening locally so we can
|
|
2600
|
-
// surface "port claimed by us but nothing is listening" (= leaked
|
|
2601
|
-
// cache file) and "port listening but not in our records" (= someone
|
|
2602
|
-
// else owns it; a new profile pointing here would collide).
|
|
2603
|
-
const portOwners = new Map();
|
|
2604
|
-
const conflicts = [];
|
|
2605
|
-
for (const s of snapshots) {
|
|
2606
|
-
const port = s.meta?.port;
|
|
2607
|
-
if (!port)
|
|
2608
|
-
continue;
|
|
2609
|
-
const occupant = getPortOccupant(port);
|
|
2610
|
-
if (!occupant) {
|
|
2611
|
-
if (s.pidAlive || s.tunnelAlive) {
|
|
2612
|
-
conflicts.push(`${s.name}: port ${port} marked active but nothing is listening`);
|
|
2613
|
-
}
|
|
2614
|
-
continue;
|
|
2615
|
-
}
|
|
2616
|
-
const ourPid = s.meta?.tunnelPid && s.meta.kind === 'tunnel'
|
|
2617
|
-
? s.meta.tunnelPid
|
|
2618
|
-
: s.meta?.pid;
|
|
2619
|
-
if (ourPid && occupant.pid !== ourPid) {
|
|
2620
|
-
conflicts.push(`${s.name}: port ${port} listened on by ${occupant.command} (pid ${occupant.pid}) but our record says pid ${ourPid}`);
|
|
2621
|
-
}
|
|
2622
|
-
portOwners.set(port, occupant);
|
|
2623
|
-
}
|
|
2624
|
-
if (opts.json) {
|
|
2625
|
-
console.log(JSON.stringify({ snapshots, conflicts }, null, 2));
|
|
2626
|
-
return;
|
|
2627
|
-
}
|
|
2628
|
-
if (snapshots.length === 0) {
|
|
2629
|
-
console.log('No tracked browser state. Run `agents browser start --profile <name>` to spawn one.');
|
|
2630
|
-
return;
|
|
2631
|
-
}
|
|
2632
|
-
console.log('PROFILE KIND PID TUNNEL PORT ALIVE TASKS OWNER');
|
|
2633
|
-
console.log('-----------------------------------------------------------------------------------------------');
|
|
2634
|
-
for (const s of snapshots) {
|
|
2635
|
-
const kind = s.meta?.kind ?? '-';
|
|
2636
|
-
const pid = s.meta?.pid ?? '-';
|
|
2637
|
-
const tunnelPid = s.meta?.tunnelPid ?? '-';
|
|
2638
|
-
const port = s.meta?.port ?? '-';
|
|
2639
|
-
const alive = aliveLabel(s);
|
|
2640
|
-
const owner = s.meta?.daemonPid
|
|
2641
|
-
? `daemon${s.daemonAlive ? '' : '(dead)'}=${s.meta.daemonPid}`
|
|
2642
|
-
: '-';
|
|
2643
|
-
console.log(`${s.name.padEnd(40)} ${String(kind).padEnd(9)} ${String(pid).padEnd(6)} ${String(tunnelPid).padEnd(7)} ${String(port).padEnd(6)} ${alive.padEnd(6)} ${String(s.taskCount).padEnd(6)} ${owner}`);
|
|
2644
|
-
}
|
|
2645
|
-
if (conflicts.length > 0) {
|
|
2646
|
-
console.log('');
|
|
2647
|
-
console.log('Conflicts / leaks detected:');
|
|
2648
|
-
for (const c of conflicts)
|
|
2649
|
-
console.log(` - ${c}`);
|
|
2650
|
-
console.log('');
|
|
2651
|
-
console.log('Run `agents browser stop --profile <name>` to clean up a specific profile, or restart the daemon to trigger the orphan reaper.');
|
|
2652
|
-
}
|
|
2653
|
-
});
|
|
2654
|
-
function aliveLabel(s) {
|
|
2655
|
-
const k = s.meta?.kind;
|
|
2656
|
-
if (k === 'tunnel')
|
|
2657
|
-
return s.tunnelAlive ? 'yes' : 'stale';
|
|
2658
|
-
return s.pidAlive ? 'yes' : 'stale';
|
|
2659
|
-
}
|
|
2660
|
-
browser
|
|
2661
|
-
.command('status')
|
|
2662
|
-
.description('Show browser service state and running browser tasks')
|
|
2663
|
-
.option('-p, --profile <name>', 'Filter by profile')
|
|
2664
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2665
|
-
.action(async (opts) => {
|
|
2666
|
-
let response;
|
|
2667
|
-
try {
|
|
2668
|
-
response = await sendIPCRequest({
|
|
2669
|
-
action: 'status',
|
|
2670
|
-
profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
|
|
2671
|
-
}, { autoStartDaemon: false });
|
|
2672
|
-
}
|
|
2673
|
-
catch (err) {
|
|
2674
|
-
if (err instanceof BrowserServiceNotRunningError) {
|
|
2675
|
-
const message = formatBrowserServiceNotRunningError();
|
|
2676
|
-
if (opts.json) {
|
|
2677
|
-
console.log(JSON.stringify({ ok: false, service: { id: 'browser-ipc', state: 'stopped' }, error: message }));
|
|
2678
|
-
}
|
|
2679
|
-
else {
|
|
2680
|
-
console.error(message);
|
|
2681
|
-
}
|
|
2682
|
-
process.exit(1);
|
|
2683
|
-
}
|
|
2684
|
-
throw err;
|
|
2685
|
-
}
|
|
2686
|
-
if (!response.ok) {
|
|
2687
|
-
if (opts.json) {
|
|
2688
|
-
console.log(JSON.stringify({ ok: false, error: response.error }));
|
|
2689
|
-
}
|
|
2690
|
-
else {
|
|
2691
|
-
console.error(response.error);
|
|
2692
|
-
}
|
|
2693
|
-
process.exit(1);
|
|
2694
|
-
}
|
|
2695
|
-
if (opts.json) {
|
|
2696
|
-
console.log(JSON.stringify(response.profiles ?? [], null, 2));
|
|
2697
|
-
return;
|
|
2698
|
-
}
|
|
2699
|
-
console.log(chalk.gray('Browser service: running (shared daemon unchanged)'));
|
|
2700
|
-
// Build flat list of tasks with profile context
|
|
2701
|
-
const allTasks = [];
|
|
2702
|
-
for (const profile of response.profiles || []) {
|
|
2703
|
-
for (const task of profile.tasks) {
|
|
2704
|
-
allTasks.push({ task, profile });
|
|
2705
|
-
}
|
|
2706
|
-
}
|
|
2707
|
-
if (allTasks.length === 0) {
|
|
2708
|
-
// Show recent history instead
|
|
2709
|
-
const historyResponse = await sendIPCRequest({ action: 'history', limit: 5 });
|
|
2710
|
-
if (historyResponse.ok && historyResponse.history && historyResponse.history.length > 0) {
|
|
2711
|
-
console.log('No active tasks. Recent history:\n');
|
|
2712
|
-
console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'DOMAINS'.padEnd(22) + 'DURATION'.padEnd(10) + 'ENDED');
|
|
2713
|
-
console.log('-'.repeat(75));
|
|
2714
|
-
for (const h of historyResponse.history) {
|
|
2715
|
-
const domains = h.domains?.slice(0, 2).join(', ') || '-';
|
|
2716
|
-
const duration = formatDuration(h.endedAt - h.createdAt);
|
|
2717
|
-
const ended = formatAge(h.endedAt);
|
|
2718
|
-
console.log(h.profile.padEnd(15) +
|
|
2719
|
-
h.name.padEnd(18) +
|
|
2720
|
-
domains.slice(0, 20).padEnd(22) +
|
|
2721
|
-
duration.padEnd(10) +
|
|
2722
|
-
ended);
|
|
2723
|
-
}
|
|
2724
|
-
console.log('\nRun `browser history` for more.');
|
|
2725
|
-
}
|
|
2726
|
-
else {
|
|
2727
|
-
console.log('No browser tasks running');
|
|
2728
|
-
}
|
|
2729
|
-
return;
|
|
2730
|
-
}
|
|
2731
|
-
// Interactive picker for TTY, plain output otherwise
|
|
2732
|
-
if (isInteractiveTerminal()) {
|
|
2733
|
-
const picked = await browserTaskPicker({
|
|
2734
|
-
message: 'Browser tasks:',
|
|
2735
|
-
tasks: allTasks,
|
|
2736
|
-
});
|
|
2737
|
-
if (picked) {
|
|
2738
|
-
// Show tab list for the selected task
|
|
2739
|
-
const tabResponse = await sendIPCRequest({
|
|
2740
|
-
action: 'tab-list',
|
|
2741
|
-
task: picked.task.task.name,
|
|
2742
|
-
});
|
|
2743
|
-
if (tabResponse.ok && tabResponse.tabs) {
|
|
2744
|
-
console.log(`\nTabs for ${picked.task.task.name}:`);
|
|
2745
|
-
for (const tab of tabResponse.tabs) {
|
|
2746
|
-
console.log(` ${tab.id} ${tab.url}`);
|
|
2747
|
-
}
|
|
2748
|
-
}
|
|
2749
|
-
}
|
|
2750
|
-
}
|
|
2751
|
-
else {
|
|
2752
|
-
// Non-interactive: simple table output
|
|
2753
|
-
for (const profile of response.profiles || []) {
|
|
2754
|
-
const portLabel = profile.configuredPort && profile.configuredPort !== profile.port
|
|
2755
|
-
? `port ${profile.port} (configured ${profile.configuredPort})`
|
|
2756
|
-
: `port ${profile.port}`;
|
|
2757
|
-
// pid 0 means the daemon attached to a browser we didn't launch — no
|
|
2758
|
-
// tracked pid. Render it as "attached" rather than the literal 0.
|
|
2759
|
-
const pidLabel = profile.pid ? `pid ${profile.pid}` : 'attached';
|
|
2760
|
-
// `profile.name` is the BARE profile; the endpoint is its own field,
|
|
2761
|
-
// so the row reads `comet-local (endpoint: endpoint-0, port …)`
|
|
2762
|
-
// instead of the raw `comet-local@endpoint-0` key (RUSH-2709).
|
|
2763
|
-
const endpointLabel = profile.endpoint ? `endpoint: ${profile.endpoint}, ` : '';
|
|
2764
|
-
console.log(`\n${profile.name} (${endpointLabel}${portLabel}, ${pidLabel})`);
|
|
2765
|
-
// A profile whose live state could not be read is still listed, and
|
|
2766
|
-
// says so — it is not silently dropped and not shown as healthy.
|
|
2767
|
-
if (profile.unavailable) {
|
|
2768
|
-
console.log(` Unavailable: ${profile.unavailable}`);
|
|
2769
|
-
}
|
|
2770
|
-
if (profile.tasks.length === 0) {
|
|
2771
|
-
console.log(' No active tasks');
|
|
2772
|
-
}
|
|
2773
|
-
else {
|
|
2774
|
-
console.log(' ' +
|
|
2775
|
-
'ID'.padEnd(12) +
|
|
2776
|
-
'LABEL'.padEnd(20) +
|
|
2777
|
-
'TABS'.padEnd(6) +
|
|
2778
|
-
'DOMAINS'.padEnd(22) +
|
|
2779
|
-
'CREATED');
|
|
2780
|
-
for (const task of profile.tasks) {
|
|
2781
|
-
const age = formatAge(task.createdAt);
|
|
2782
|
-
const id = (task.name || task.id).slice(0, 10);
|
|
2783
|
-
const label = (task.label || task.name || task.id).slice(0, 18);
|
|
2784
|
-
const domains = task.domains?.slice(0, 2).join(', ') || '-';
|
|
2785
|
-
console.log(' ' +
|
|
2786
|
-
id.padEnd(12) +
|
|
2787
|
-
label.padEnd(20) +
|
|
2788
|
-
String(task.tabCount).padEnd(6) +
|
|
2789
|
-
domains.slice(0, 20).padEnd(22) +
|
|
2790
|
-
age);
|
|
2791
|
-
// The recorded tab count above is what the task owns on disk; this
|
|
2792
|
-
// says the live tabs behind it could not be read, so the count is
|
|
2793
|
-
// not mistaken for verified-live tabs.
|
|
2794
|
-
if (task.unavailable) {
|
|
2795
|
-
console.log(' '.padEnd(14) + `↳ tabs unreadable: ${task.unavailable}`);
|
|
2796
|
-
}
|
|
2797
|
-
}
|
|
2798
|
-
}
|
|
2799
|
-
}
|
|
2800
|
-
}
|
|
2801
|
-
});
|
|
2802
|
-
browser
|
|
2803
|
-
.command('tasks')
|
|
2804
|
-
.description('List all browser tasks')
|
|
2805
|
-
.option('-p, --profile <name>', 'Filter by profile')
|
|
2806
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2807
|
-
.action(async (opts) => {
|
|
2808
|
-
const response = await sendIPCRequest({
|
|
2809
|
-
action: 'status',
|
|
2810
|
-
profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
|
|
2811
|
-
});
|
|
2812
|
-
if (!response.ok) {
|
|
2813
|
-
if (opts.json) {
|
|
2814
|
-
console.log(JSON.stringify({ ok: false, error: response.error }));
|
|
2815
|
-
}
|
|
2816
|
-
else {
|
|
2817
|
-
console.error(response.error);
|
|
2818
|
-
}
|
|
2819
|
-
process.exit(1);
|
|
2820
|
-
}
|
|
2821
|
-
const allTasks = [];
|
|
2822
|
-
for (const profile of response.profiles || []) {
|
|
2823
|
-
for (const task of profile.tasks) {
|
|
2824
|
-
allTasks.push({
|
|
2825
|
-
profile: profile.name,
|
|
2826
|
-
name: task.name || task.id,
|
|
2827
|
-
tabs: task.tabCount,
|
|
2828
|
-
domains: task.domains || [],
|
|
2829
|
-
created: task.createdAt,
|
|
2830
|
-
});
|
|
2831
|
-
}
|
|
2832
|
-
}
|
|
2833
|
-
if (opts.json) {
|
|
2834
|
-
console.log(JSON.stringify(allTasks, null, 2));
|
|
2835
|
-
return;
|
|
2836
|
-
}
|
|
2837
|
-
if (allTasks.length === 0) {
|
|
2838
|
-
// Show recent history instead
|
|
2839
|
-
const historyResponse = await sendIPCRequest({ action: 'history', limit: 5 });
|
|
2840
|
-
if (historyResponse.ok && historyResponse.history && historyResponse.history.length > 0) {
|
|
2841
|
-
console.log('No active tasks. Recent history:\n');
|
|
2842
|
-
console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'DOMAINS'.padEnd(22) + 'DURATION'.padEnd(10) + 'ENDED');
|
|
2843
|
-
console.log('-'.repeat(75));
|
|
2844
|
-
for (const h of historyResponse.history) {
|
|
2845
|
-
const domains = h.domains?.slice(0, 2).join(', ') || '-';
|
|
2846
|
-
const duration = formatDuration(h.endedAt - h.createdAt);
|
|
2847
|
-
const ended = formatAge(h.endedAt);
|
|
2848
|
-
console.log(h.profile.padEnd(15) +
|
|
2849
|
-
h.name.padEnd(18) +
|
|
2850
|
-
domains.slice(0, 20).padEnd(22) +
|
|
2851
|
-
duration.padEnd(10) +
|
|
2852
|
-
ended);
|
|
2853
|
-
}
|
|
2854
|
-
}
|
|
2855
|
-
else {
|
|
2856
|
-
console.log('No active tasks');
|
|
2857
|
-
}
|
|
2858
|
-
return;
|
|
2859
|
-
}
|
|
2860
|
-
console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'TABS'.padEnd(6) + 'DOMAINS'.padEnd(22) + 'CREATED');
|
|
2861
|
-
console.log('-'.repeat(70));
|
|
2862
|
-
for (const t of allTasks) {
|
|
2863
|
-
const domains = t.domains.slice(0, 2).join(', ') || '-';
|
|
2864
|
-
console.log(t.profile.padEnd(15) +
|
|
2865
|
-
t.name.padEnd(18) +
|
|
2866
|
-
String(t.tabs).padEnd(6) +
|
|
2867
|
-
domains.slice(0, 20).padEnd(22) +
|
|
2868
|
-
formatAge(t.created));
|
|
2869
|
-
}
|
|
2870
|
-
});
|
|
2871
|
-
browser
|
|
2872
|
-
.command('history')
|
|
2873
|
-
.description('Show recent browser task history')
|
|
2874
|
-
.option('-l, --limit <n>', 'Number of entries (default 10)', '10')
|
|
2875
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2876
|
-
.action(async (opts) => {
|
|
2877
|
-
const response = await sendIPCRequest({
|
|
2878
|
-
action: 'history',
|
|
2879
|
-
limit: parseInt(opts.limit, 10),
|
|
2880
|
-
});
|
|
2881
|
-
if (!response.ok) {
|
|
2882
|
-
if (opts.json) {
|
|
2883
|
-
console.log(JSON.stringify({ ok: false, error: response.error }));
|
|
2884
|
-
}
|
|
2885
|
-
else {
|
|
2886
|
-
console.error(response.error);
|
|
2887
|
-
}
|
|
2888
|
-
process.exit(1);
|
|
2889
|
-
}
|
|
2890
|
-
if (opts.json) {
|
|
2891
|
-
console.log(JSON.stringify(response.history ?? [], null, 2));
|
|
2892
|
-
return;
|
|
2893
|
-
}
|
|
2894
|
-
if (!response.history || response.history.length === 0) {
|
|
2895
|
-
console.log('No browser task history');
|
|
2896
|
-
return;
|
|
2897
|
-
}
|
|
2898
|
-
console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'DOMAINS'.padEnd(22) + 'DURATION'.padEnd(10) + 'ENDED');
|
|
2899
|
-
console.log('-'.repeat(75));
|
|
2900
|
-
for (const h of response.history) {
|
|
2901
|
-
const domains = h.domains?.slice(0, 2).join(', ') || '-';
|
|
2902
|
-
const duration = formatDuration(h.endedAt - h.createdAt);
|
|
2903
|
-
const ended = formatAge(h.endedAt);
|
|
2904
|
-
console.log(h.profile.padEnd(15) +
|
|
2905
|
-
h.name.padEnd(18) +
|
|
2906
|
-
domains.slice(0, 20).padEnd(22) +
|
|
2907
|
-
duration.padEnd(10) +
|
|
2908
|
-
ended);
|
|
2909
|
-
}
|
|
2910
|
-
});
|
|
2911
|
-
browser
|
|
2912
|
-
.command('refs')
|
|
2913
|
-
.description('Get DOM refs for interactive elements')
|
|
2914
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2915
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2916
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
2917
|
-
.option('--all', 'Include non-interactive elements')
|
|
2918
|
-
.option('-l, --limit <n>', 'Max elements (default 500)', '500')
|
|
2919
|
-
.option('--json', 'Output machine-readable JSON')
|
|
2920
|
-
.action(async (opts) => {
|
|
2921
|
-
const task = resolveTaskName(opts);
|
|
2922
|
-
const response = await sendIPCRequest({
|
|
2923
|
-
action: 'refs',
|
|
2924
|
-
task,
|
|
2925
|
-
tabId: opts.tab,
|
|
2926
|
-
interactive: !opts.all,
|
|
2927
|
-
limit: parseInt(opts.limit, 10),
|
|
2928
|
-
});
|
|
2929
|
-
if (!response.ok) {
|
|
2930
|
-
if (opts.json) {
|
|
2931
|
-
console.log(JSON.stringify({ ok: false, error: response.error }));
|
|
2932
|
-
}
|
|
2933
|
-
else {
|
|
2934
|
-
console.error(response.error);
|
|
2935
|
-
}
|
|
2936
|
-
process.exit(1);
|
|
2937
|
-
}
|
|
2938
|
-
// Warn (on stderr, so it never corrupts --json stdout) when the a11y tree
|
|
2939
|
-
// exposed nothing to click — the caller should fall back to a screenshot
|
|
2940
|
-
// plus coordinate click.
|
|
2941
|
-
const count = response.nodes?.length ?? 0;
|
|
2942
|
-
if (count === 0) {
|
|
2943
|
-
const scope = opts.all ? 'elements' : 'interactive elements';
|
|
2944
|
-
console.error(`No ${scope} found. The page may render its UI on a canvas or in a custom widget ` +
|
|
2945
|
-
`the accessibility tree doesn't expose. Take a screenshot ` +
|
|
2946
|
-
`(\`browser screenshot\`) and click by position with \`browser click --at X,Y\`.`);
|
|
2947
|
-
}
|
|
2948
|
-
if (opts.json) {
|
|
2949
|
-
console.log(JSON.stringify(response.nodes ?? [], null, 2));
|
|
2950
|
-
return;
|
|
2951
|
-
}
|
|
2952
|
-
console.log(response.refs);
|
|
2953
|
-
});
|
|
2954
|
-
browser
|
|
2955
|
-
.command('click [ref]')
|
|
2956
|
-
.description('Click an element by ref, or raw coordinates with --at X,Y')
|
|
2957
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
2958
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
2959
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
2960
|
-
.option('--at <x,y>', 'Click viewport coordinates (e.g. --at 320,540), bypassing ref resolution')
|
|
2961
|
-
.action(async (ref, opts) => {
|
|
2962
|
-
const task = resolveTaskName(opts);
|
|
2963
|
-
let requestExtra;
|
|
2964
|
-
if (opts.at !== undefined) {
|
|
2965
|
-
const parts = String(opts.at).split(',').map((s) => Number(s.trim()));
|
|
2966
|
-
if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n))) {
|
|
2967
|
-
console.error(`Invalid --at value "${opts.at}". Expected X,Y (e.g. --at 320,540).`);
|
|
2968
|
-
process.exit(1);
|
|
2969
|
-
}
|
|
2970
|
-
requestExtra = { atX: parts[0], atY: parts[1] };
|
|
2971
|
-
}
|
|
2972
|
-
else if (ref !== undefined) {
|
|
2973
|
-
const parsed = parseInt(ref, 10);
|
|
2974
|
-
if (!Number.isFinite(parsed)) {
|
|
2975
|
-
console.error(`Invalid ref "${ref}". Pass an integer ref or use --at X,Y.`);
|
|
2976
|
-
process.exit(1);
|
|
2977
|
-
}
|
|
2978
|
-
requestExtra = { ref: parsed };
|
|
2979
|
-
}
|
|
2980
|
-
else {
|
|
2981
|
-
console.error('Provide a ref (e.g. `click 5`) or coordinates (`click --at X,Y`).');
|
|
2982
|
-
process.exit(1);
|
|
2983
|
-
return;
|
|
2984
|
-
}
|
|
2985
|
-
const response = await sendIPCRequest({
|
|
2986
|
-
action: 'click',
|
|
2987
|
-
task,
|
|
2988
|
-
tabId: opts.tab,
|
|
2989
|
-
...requestExtra,
|
|
2990
|
-
});
|
|
2991
|
-
if (!response.ok) {
|
|
2992
|
-
console.error(response.error);
|
|
2993
|
-
process.exit(1);
|
|
2994
|
-
}
|
|
2995
|
-
console.log(response.message ? `Clicked (${response.message})` : 'Clicked');
|
|
2996
|
-
});
|
|
2997
|
-
browser
|
|
2998
|
-
.command('type <ref>')
|
|
2999
|
-
.description('Type text into an element by ref')
|
|
3000
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3001
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3002
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3003
|
-
.option('--text <text>', 'Text to type (use quotes for spaces/special chars)')
|
|
3004
|
-
.option('--secret <ref>', 'Resolve <bundle>/<KEY> from `agents secrets` and type the value. The secret is resolved in-process and never printed to stdout or the transcript. Alternative to --text.')
|
|
3005
|
-
.option('--clear', 'Clear editor content before typing')
|
|
3006
|
-
.action(async (ref, opts) => {
|
|
3007
|
-
const task = resolveTaskName(opts);
|
|
3008
|
-
const refNum = parseInt(ref, 10);
|
|
3009
|
-
if (!Number.isFinite(refNum)) {
|
|
3010
|
-
console.error(`<ref> must be an integer, got: ${ref}`);
|
|
3011
|
-
process.exit(1);
|
|
3012
|
-
}
|
|
3013
|
-
// Resolve the text to type: literal --text, or a --secret <bundle>/<KEY>
|
|
3014
|
-
// reference resolved in-process so the value never crosses stdout/transcript.
|
|
3015
|
-
let text = opts.text;
|
|
3016
|
-
if (opts.secret) {
|
|
3017
|
-
if (opts.text !== undefined) {
|
|
3018
|
-
console.error('Pass either --text or --secret, not both.');
|
|
3019
|
-
process.exit(1);
|
|
3020
|
-
}
|
|
3021
|
-
const parsed = parseSecretRef(opts.secret);
|
|
3022
|
-
if (!parsed) {
|
|
3023
|
-
console.error(`--secret must be <bundle>/<KEY>, got: ${opts.secret}`);
|
|
3024
|
-
process.exit(1);
|
|
3025
|
-
}
|
|
3026
|
-
if (!(await bundleExists(parsed.bundle))) {
|
|
3027
|
-
console.error(`Secrets bundle "${parsed.bundle}" not found.`);
|
|
3028
|
-
process.exit(1);
|
|
3029
|
-
}
|
|
3030
|
-
try {
|
|
3031
|
-
const { env } = await readAndResolveBundleEnv(parsed.bundle, { caller: 'browser type', keys: [parsed.key], keyMode: 'storage', agentOnly: true });
|
|
3032
|
-
if (!(parsed.key in env)) {
|
|
3033
|
-
console.error(`Key "${parsed.key}" not in bundle "${parsed.bundle}".`);
|
|
3034
|
-
process.exit(1);
|
|
3035
|
-
}
|
|
3036
|
-
text = env[parsed.key];
|
|
3037
|
-
}
|
|
3038
|
-
catch (err) {
|
|
3039
|
-
console.error(err.message);
|
|
3040
|
-
process.exit(1);
|
|
3041
|
-
}
|
|
3042
|
-
}
|
|
3043
|
-
if (text === undefined) {
|
|
3044
|
-
console.error('Provide --text <text> or --secret <bundle>/<KEY>.');
|
|
3045
|
-
process.exit(1);
|
|
3046
|
-
}
|
|
3047
|
-
const response = await sendIPCRequest({
|
|
3048
|
-
action: 'type',
|
|
3049
|
-
task,
|
|
3050
|
-
tabId: opts.tab,
|
|
3051
|
-
ref: refNum,
|
|
3052
|
-
text,
|
|
3053
|
-
clear: opts.clear,
|
|
3054
|
-
});
|
|
3055
|
-
if (!response.ok) {
|
|
3056
|
-
console.error(response.error);
|
|
3057
|
-
process.exit(1);
|
|
3058
|
-
}
|
|
3059
|
-
// Never echo the typed text — with --secret it would leak the credential.
|
|
3060
|
-
console.log('Typed');
|
|
3061
|
-
});
|
|
3062
|
-
browser
|
|
3063
|
-
.command('press <key>')
|
|
3064
|
-
.description('Press a key (Enter, Tab, Escape, etc)')
|
|
3065
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3066
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3067
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3068
|
-
.action(async (key, opts) => {
|
|
3069
|
-
const task = resolveTaskName(opts);
|
|
3070
|
-
const response = await sendIPCRequest({
|
|
3071
|
-
action: 'press',
|
|
3072
|
-
task,
|
|
3073
|
-
tabId: opts.tab,
|
|
3074
|
-
key,
|
|
3075
|
-
});
|
|
3076
|
-
if (!response.ok) {
|
|
3077
|
-
console.error(response.error);
|
|
3078
|
-
process.exit(1);
|
|
3079
|
-
}
|
|
3080
|
-
console.log('Pressed');
|
|
3081
|
-
});
|
|
3082
|
-
browser
|
|
3083
|
-
.command('hover <ref>')
|
|
3084
|
-
.description('Hover over an element by ref')
|
|
3085
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3086
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3087
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3088
|
-
.action(async (ref, opts) => {
|
|
3089
|
-
const task = resolveTaskName(opts);
|
|
3090
|
-
const response = await sendIPCRequest({
|
|
3091
|
-
action: 'hover',
|
|
3092
|
-
task,
|
|
3093
|
-
tabId: opts.tab,
|
|
3094
|
-
ref: parseInt(ref, 10),
|
|
3095
|
-
});
|
|
3096
|
-
if (!response.ok) {
|
|
3097
|
-
console.error(response.error);
|
|
3098
|
-
process.exit(1);
|
|
3099
|
-
}
|
|
3100
|
-
console.log('Hovered');
|
|
3101
|
-
});
|
|
3102
|
-
browser
|
|
3103
|
-
.command('scroll')
|
|
3104
|
-
.description('Scroll the page by pixel amount (negatives scroll up/left)')
|
|
3105
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3106
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3107
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3108
|
-
.option('--dx <n>', 'Horizontal pixels (negative = left)', (v) => parseInt(v, 10), 0)
|
|
3109
|
-
.option('--dy <n>', 'Vertical pixels (negative = up)', (v) => parseInt(v, 10), 0)
|
|
3110
|
-
.option('-x, --at-x <x>', 'X coordinate to dispatch scroll from (default 0)', parseInt)
|
|
3111
|
-
.option('-y, --at-y <y>', 'Y coordinate to dispatch scroll from (default 0)', parseInt)
|
|
3112
|
-
.action(async (opts) => {
|
|
3113
|
-
const task = resolveTaskName(opts);
|
|
3114
|
-
if (!Number.isFinite(opts.dx) || !Number.isFinite(opts.dy)) {
|
|
3115
|
-
console.error('--dx and --dy must be integers');
|
|
3116
|
-
process.exit(1);
|
|
3117
|
-
}
|
|
3118
|
-
if (opts.dx === 0 && opts.dy === 0) {
|
|
3119
|
-
console.error('Pass --dx and/or --dy (at least one must be non-zero)');
|
|
3120
|
-
process.exit(1);
|
|
3121
|
-
}
|
|
3122
|
-
const response = await sendIPCRequest({
|
|
3123
|
-
action: 'scroll',
|
|
3124
|
-
task,
|
|
3125
|
-
tabId: opts.tab,
|
|
3126
|
-
scrollX: opts.dx,
|
|
3127
|
-
scrollY: opts.dy,
|
|
3128
|
-
scrollAtX: opts.atX,
|
|
3129
|
-
scrollAtY: opts.atY,
|
|
3130
|
-
});
|
|
3131
|
-
if (!response.ok) {
|
|
3132
|
-
console.error(response.error);
|
|
3133
|
-
process.exit(1);
|
|
3134
|
-
}
|
|
3135
|
-
console.log('Scrolled');
|
|
3136
|
-
});
|
|
3137
|
-
browser
|
|
3138
|
-
.command('upload')
|
|
3139
|
-
.description('Upload file(s) — supports hidden file inputs, drag-drop targets, and OS chooser interception')
|
|
3140
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3141
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3142
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3143
|
-
.option('-r, --ref <n>', 'Ref of the upload target element (file input or drop zone)', (v) => parseInt(v, 10))
|
|
3144
|
-
.option('--trigger <n>', 'Ref of a button that opens the OS file chooser (Pattern C)', (v) => parseInt(v, 10))
|
|
3145
|
-
.option('-f, --file <path...>', 'Absolute path(s) to file(s) to upload (repeatable)')
|
|
3146
|
-
.option('--drop', 'Force drag-drop pattern even if ref is an <input type=file>')
|
|
3147
|
-
.option('--input', 'Force file-input pattern (DOM.setFileInputFiles)')
|
|
3148
|
-
.option('--timeout <ms>', 'Timeout for chooser interception (Pattern C)', (v) => parseInt(v, 10))
|
|
3149
|
-
.action(async (opts) => {
|
|
3150
|
-
const task = resolveTaskName(opts);
|
|
3151
|
-
const files = opts.file ?? [];
|
|
3152
|
-
if (files.length === 0) {
|
|
3153
|
-
console.error('--file <path> is required (repeat for multiple files)');
|
|
3154
|
-
process.exit(1);
|
|
3155
|
-
}
|
|
3156
|
-
if (opts.ref === undefined && opts.trigger === undefined) {
|
|
3157
|
-
console.error('--ref <n> or --trigger <n> is required');
|
|
3158
|
-
process.exit(1);
|
|
3159
|
-
}
|
|
3160
|
-
if (opts.drop && opts.input) {
|
|
3161
|
-
console.error('--drop and --input are mutually exclusive');
|
|
3162
|
-
process.exit(1);
|
|
3163
|
-
}
|
|
3164
|
-
let mode = 'auto';
|
|
3165
|
-
if (opts.trigger !== undefined)
|
|
3166
|
-
mode = 'chooser';
|
|
3167
|
-
else if (opts.drop)
|
|
3168
|
-
mode = 'drop';
|
|
3169
|
-
else if (opts.input)
|
|
3170
|
-
mode = 'input';
|
|
3171
|
-
const response = await sendIPCRequest({
|
|
3172
|
-
action: 'upload',
|
|
3173
|
-
task,
|
|
3174
|
-
tabId: opts.tab,
|
|
3175
|
-
ref: opts.ref,
|
|
3176
|
-
trigger: opts.trigger,
|
|
3177
|
-
files,
|
|
3178
|
-
uploadMode: mode,
|
|
3179
|
-
timeout: opts.timeout,
|
|
3180
|
-
});
|
|
3181
|
-
if (!response.ok) {
|
|
3182
|
-
console.error(response.error);
|
|
3183
|
-
process.exit(1);
|
|
3184
|
-
}
|
|
3185
|
-
console.log(`Uploaded ${files.length} file${files.length === 1 ? '' : 's'} (${response.uploadMode})`);
|
|
3186
|
-
});
|
|
3187
|
-
// ─── Viewport & Device ───────────────────────────────────────────────────────
|
|
3188
|
-
const setCmd = browser.command('set').description('Set browser emulation options');
|
|
3189
|
-
setCmd
|
|
3190
|
-
.command('viewport <width> <height>')
|
|
3191
|
-
.description('Set viewport size')
|
|
3192
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3193
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3194
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3195
|
-
.option('-m, --mobile', 'Enable mobile emulation')
|
|
3196
|
-
.option('-s, --scale <factor>', 'Device scale factor', parseFloat)
|
|
3197
|
-
.action(async (width, height, opts) => {
|
|
3198
|
-
const task = resolveTaskName(opts);
|
|
3199
|
-
const response = await sendIPCRequest({
|
|
3200
|
-
action: 'set-viewport',
|
|
3201
|
-
task,
|
|
3202
|
-
tabId: opts.tab,
|
|
3203
|
-
width: parseInt(width, 10),
|
|
3204
|
-
height: parseInt(height, 10),
|
|
3205
|
-
mobile: opts.mobile,
|
|
3206
|
-
deviceScaleFactor: opts.scale,
|
|
3207
|
-
});
|
|
3208
|
-
if (!response.ok) {
|
|
3209
|
-
console.error(response.error);
|
|
3210
|
-
process.exit(1);
|
|
3211
|
-
}
|
|
3212
|
-
console.log(`Viewport set to ${width}x${height}${opts.mobile ? ' (mobile)' : ''}`);
|
|
3213
|
-
});
|
|
3214
|
-
setCmd
|
|
3215
|
-
.command('device <device-name>')
|
|
3216
|
-
.description('Emulate a device (iPhone 14, iPad, MacBook Pro)')
|
|
3217
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3218
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3219
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3220
|
-
.action(async (deviceName, opts) => {
|
|
3221
|
-
const task = resolveTaskName(opts);
|
|
3222
|
-
const response = await sendIPCRequest({
|
|
3223
|
-
action: 'set-device',
|
|
3224
|
-
task,
|
|
3225
|
-
tabId: opts.tab,
|
|
3226
|
-
deviceName,
|
|
3227
|
-
});
|
|
3228
|
-
if (!response.ok) {
|
|
3229
|
-
console.error(response.error);
|
|
3230
|
-
process.exit(1);
|
|
3231
|
-
}
|
|
3232
|
-
console.log(`Device set to ${deviceName}`);
|
|
3233
|
-
});
|
|
3234
|
-
browser
|
|
3235
|
-
.command('devices')
|
|
3236
|
-
.description('List available device presets')
|
|
3237
|
-
.action(async () => {
|
|
3238
|
-
const { DEVICES } = await import('../lib/browser/devices.js');
|
|
3239
|
-
console.log('Available devices:');
|
|
3240
|
-
for (const [name, desc] of Object.entries(DEVICES)) {
|
|
3241
|
-
console.log(` ${name.padEnd(16)} ${desc.width}x${desc.height} @${desc.deviceScaleFactor}x${desc.mobile ? ' (mobile)' : ''}`);
|
|
3242
|
-
}
|
|
3243
|
-
});
|
|
3244
|
-
// ─── Console & Errors ────────────────────────────────────────────────────────
|
|
3245
|
-
browser
|
|
3246
|
-
.command('console')
|
|
3247
|
-
.description('Read console logs from a tab')
|
|
3248
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3249
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3250
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3251
|
-
.option('-l, --level <level>', 'Filter by level (log, info, warn, error)')
|
|
3252
|
-
.option('--clear', 'Clear logs after reading')
|
|
3253
|
-
.action(async (opts) => {
|
|
3254
|
-
const task = resolveTaskName(opts);
|
|
3255
|
-
const response = await sendIPCRequest({
|
|
3256
|
-
action: 'console',
|
|
3257
|
-
task,
|
|
3258
|
-
tabId: opts.tab,
|
|
3259
|
-
level: opts.level,
|
|
3260
|
-
clear: opts.clear,
|
|
3261
|
-
});
|
|
3262
|
-
if (!response.ok) {
|
|
3263
|
-
console.error(response.error);
|
|
3264
|
-
process.exit(1);
|
|
3265
|
-
}
|
|
3266
|
-
if (!response.logs || response.logs.length === 0) {
|
|
3267
|
-
console.log('No console logs');
|
|
3268
|
-
return;
|
|
3269
|
-
}
|
|
3270
|
-
for (const log of response.logs) {
|
|
3271
|
-
const prefix = `[${log.level.toUpperCase()}]`.padEnd(8);
|
|
3272
|
-
const loc = log.url ? ` (${log.url}${log.line ? `:${log.line}` : ''})` : '';
|
|
3273
|
-
console.log(`${prefix} ${log.text}${loc}`);
|
|
3274
|
-
}
|
|
3275
|
-
});
|
|
3276
|
-
browser
|
|
3277
|
-
.command('errors')
|
|
3278
|
-
.description('Read page errors from a tab')
|
|
3279
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3280
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3281
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3282
|
-
.option('--clear', 'Clear errors after reading')
|
|
3283
|
-
.action(async (opts) => {
|
|
3284
|
-
const task = resolveTaskName(opts);
|
|
3285
|
-
const response = await sendIPCRequest({
|
|
3286
|
-
action: 'errors',
|
|
3287
|
-
task,
|
|
3288
|
-
tabId: opts.tab,
|
|
3289
|
-
clear: opts.clear,
|
|
3290
|
-
});
|
|
3291
|
-
if (!response.ok) {
|
|
3292
|
-
console.error(response.error);
|
|
3293
|
-
process.exit(1);
|
|
3294
|
-
}
|
|
3295
|
-
if (!response.errors || response.errors.length === 0) {
|
|
3296
|
-
console.log('No errors');
|
|
3297
|
-
return;
|
|
3298
|
-
}
|
|
3299
|
-
for (const err of response.errors) {
|
|
3300
|
-
console.log(`[ERROR] ${err.message}`);
|
|
3301
|
-
if (err.stack)
|
|
3302
|
-
console.log(err.stack);
|
|
3303
|
-
if (err.url)
|
|
3304
|
-
console.log(` at ${err.url}${err.line ? `:${err.line}` : ''}`);
|
|
3305
|
-
console.log();
|
|
3306
|
-
}
|
|
3307
|
-
});
|
|
3308
|
-
// ─── Network ─────────────────────────────────────────────────────────────────
|
|
3309
|
-
browser
|
|
3310
|
-
.command('requests')
|
|
3311
|
-
.description('Read captured network requests. --format har emits a HAR 1.2 JSON document.')
|
|
3312
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3313
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3314
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3315
|
-
.option('-f, --filter <text>', 'Filter URLs containing text')
|
|
3316
|
-
.option('--clear', 'Clear requests after reading')
|
|
3317
|
-
.option('--format <format>', 'Output format: table (default) or har', 'table')
|
|
3318
|
-
.action(async (opts) => {
|
|
3319
|
-
const task = resolveTaskName(opts);
|
|
3320
|
-
if (opts.format !== 'table' && opts.format !== 'har') {
|
|
3321
|
-
console.error('--format must be "table" or "har"');
|
|
3322
|
-
process.exit(1);
|
|
3323
|
-
}
|
|
3324
|
-
const response = await sendIPCRequest({
|
|
3325
|
-
action: 'requests',
|
|
3326
|
-
task,
|
|
3327
|
-
tabId: opts.tab,
|
|
3328
|
-
filter: opts.filter,
|
|
3329
|
-
clear: opts.clear,
|
|
3330
|
-
});
|
|
3331
|
-
if (!response.ok) {
|
|
3332
|
-
console.error(response.error);
|
|
3333
|
-
process.exit(1);
|
|
3334
|
-
}
|
|
3335
|
-
const requests = response.requests ?? [];
|
|
3336
|
-
if (opts.format === 'har') {
|
|
3337
|
-
const har = buildHar(requests, {
|
|
3338
|
-
creatorName: 'agents-cli',
|
|
3339
|
-
creatorVersion: getCliVersion(),
|
|
3340
|
-
});
|
|
3341
|
-
console.log(JSON.stringify(har, null, 2));
|
|
3342
|
-
return;
|
|
3343
|
-
}
|
|
3344
|
-
if (requests.length === 0) {
|
|
3345
|
-
console.log('No requests captured');
|
|
3346
|
-
return;
|
|
3347
|
-
}
|
|
3348
|
-
console.log('METHOD'.padEnd(8) + 'STATUS'.padEnd(8) + 'URL');
|
|
3349
|
-
console.log('-'.repeat(72));
|
|
3350
|
-
for (const req of requests) {
|
|
3351
|
-
const status = req.status ? String(req.status) : '...';
|
|
3352
|
-
console.log(`${req.method.padEnd(8)}${status.padEnd(8)}${req.url.slice(0, 100)}`);
|
|
3353
|
-
}
|
|
3354
|
-
});
|
|
3355
|
-
browser
|
|
3356
|
-
.command('logs')
|
|
3357
|
-
.description('Read merged rush-app + rush-cli JSONL logs for a task')
|
|
3358
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3359
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3360
|
-
.option('--source <name>', 'Source to scope to: rush-app or rush-cli (default both)')
|
|
3361
|
-
.option('--lines <n>', 'Tail N entries (default 200; ignored when --since)', (v) => parseInt(v, 10))
|
|
3362
|
-
.option('--since <when>', 'Absolute timestamp or relative offset (e.g. 5m, 2h, 1d)')
|
|
3363
|
-
.option('--until <when>', 'Absolute timestamp or relative offset (e.g. 5m, 2h, 1d)')
|
|
3364
|
-
.option('--level <level>', 'Filter entries by level field')
|
|
3365
|
-
.option('--message <name>', 'Filter entries by exact message field')
|
|
3366
|
-
.option('--filter <text>', 'Filter entries whose JSON contains this substring')
|
|
3367
|
-
.option('-f, --follow', 'Follow mode (not yet implemented)')
|
|
3368
|
-
.action(async (opts) => {
|
|
3369
|
-
if (opts.follow) {
|
|
3370
|
-
process.stderr.write('follow mode not yet implemented; coming next pass\n');
|
|
3371
|
-
process.exit(1);
|
|
3372
|
-
}
|
|
3373
|
-
const task = resolveTaskName(opts);
|
|
3374
|
-
const response = await sendIPCRequest({
|
|
3375
|
-
action: 'getAppLogs',
|
|
3376
|
-
task,
|
|
3377
|
-
source: opts.source,
|
|
3378
|
-
lines: opts.lines,
|
|
3379
|
-
since: opts.since,
|
|
3380
|
-
until: opts.until,
|
|
3381
|
-
appLevel: opts.level,
|
|
3382
|
-
message: opts.message,
|
|
3383
|
-
filter: opts.filter,
|
|
3384
|
-
});
|
|
3385
|
-
if (!response.ok) {
|
|
3386
|
-
console.error(response.error);
|
|
3387
|
-
process.exit(1);
|
|
3388
|
-
}
|
|
3389
|
-
const entries = response.appLogs ?? [];
|
|
3390
|
-
for (const entry of entries) {
|
|
3391
|
-
console.log(JSON.stringify(entry));
|
|
3392
|
-
}
|
|
3393
|
-
});
|
|
3394
|
-
browser
|
|
3395
|
-
.command('responsebody <url-pattern>')
|
|
3396
|
-
.description('Wait for and read a response body by URL pattern')
|
|
3397
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3398
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3399
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3400
|
-
.option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
|
|
3401
|
-
.option('--max-chars <n>', 'Max characters to return', parseInt)
|
|
3402
|
-
.action(async (urlPattern, opts) => {
|
|
3403
|
-
const task = resolveTaskName(opts);
|
|
3404
|
-
const response = await sendIPCRequest({
|
|
3405
|
-
action: 'response-body',
|
|
3406
|
-
task,
|
|
3407
|
-
tabId: opts.tab,
|
|
3408
|
-
urlPattern,
|
|
3409
|
-
timeout: opts.timeout,
|
|
3410
|
-
maxChars: opts.maxChars,
|
|
3411
|
-
});
|
|
3412
|
-
if (!response.ok) {
|
|
3413
|
-
console.error(response.error);
|
|
3414
|
-
process.exit(1);
|
|
3415
|
-
}
|
|
3416
|
-
console.log(response.body);
|
|
3417
|
-
});
|
|
3418
|
-
// ─── Wait ────────────────────────────────────────────────────────────────────
|
|
3419
|
-
browser
|
|
3420
|
-
.command('wait')
|
|
3421
|
-
.description('Wait for a condition')
|
|
3422
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3423
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3424
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3425
|
-
.option('--time <ms>', 'Wait for milliseconds')
|
|
3426
|
-
.option('--selector <css>', 'Wait for CSS selector to appear')
|
|
3427
|
-
.option('--url <pattern>', 'Wait for URL to match pattern')
|
|
3428
|
-
.option('--fn <js>', 'Wait for JS expression to return truthy')
|
|
3429
|
-
.option('--state <state>', 'Wait for load state (domcontentloaded, load, networkidle)')
|
|
3430
|
-
.option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
|
|
3431
|
-
.action(async (opts) => {
|
|
3432
|
-
const task = resolveTaskName(opts);
|
|
3433
|
-
let waitType;
|
|
3434
|
-
let waitValue;
|
|
3435
|
-
if (opts.time) {
|
|
3436
|
-
waitType = 'time';
|
|
3437
|
-
waitValue = parseInt(opts.time, 10);
|
|
3438
|
-
}
|
|
3439
|
-
else if (opts.selector) {
|
|
3440
|
-
waitType = 'selector';
|
|
3441
|
-
waitValue = opts.selector;
|
|
3442
|
-
}
|
|
3443
|
-
else if (opts.url) {
|
|
3444
|
-
waitType = 'url';
|
|
3445
|
-
waitValue = opts.url;
|
|
3446
|
-
}
|
|
3447
|
-
else if (opts.fn) {
|
|
3448
|
-
waitType = 'function';
|
|
3449
|
-
waitValue = opts.fn;
|
|
3450
|
-
}
|
|
3451
|
-
else if (opts.state) {
|
|
3452
|
-
waitType = 'load';
|
|
3453
|
-
waitValue = opts.state;
|
|
3454
|
-
}
|
|
3455
|
-
else {
|
|
3456
|
-
console.error('One of --time, --selector, --url, --fn, or --state required');
|
|
3457
|
-
process.exit(1);
|
|
3458
|
-
}
|
|
3459
|
-
const response = await sendIPCRequest({
|
|
3460
|
-
action: 'wait',
|
|
3461
|
-
task,
|
|
3462
|
-
tabId: opts.tab,
|
|
3463
|
-
waitType,
|
|
3464
|
-
waitValue,
|
|
3465
|
-
timeout: opts.timeout,
|
|
3466
|
-
});
|
|
3467
|
-
if (!response.ok) {
|
|
3468
|
-
console.error(response.error);
|
|
3469
|
-
process.exit(1);
|
|
3470
|
-
}
|
|
3471
|
-
console.log('Wait condition met');
|
|
3472
|
-
});
|
|
3473
|
-
// ─── Downloads ───────────────────────────────────────────────────────────────
|
|
3474
|
-
browser
|
|
3475
|
-
.command('download')
|
|
3476
|
-
.description("Set the download directory for a task (defaults to the profile's downloads dir)")
|
|
3477
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3478
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3479
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3480
|
-
.option('-p, --path <dir>', "Download directory path (default: the profile's downloads dir)")
|
|
3481
|
-
.action(async (opts) => {
|
|
3482
|
-
const task = resolveTaskName(opts);
|
|
3483
|
-
const response = await sendIPCRequest({
|
|
3484
|
-
action: 'set-download-path',
|
|
3485
|
-
task,
|
|
3486
|
-
tabId: opts.tab,
|
|
3487
|
-
downloadPath: opts.path,
|
|
3488
|
-
});
|
|
3489
|
-
if (!response.ok) {
|
|
3490
|
-
console.error(response.error);
|
|
3491
|
-
process.exit(1);
|
|
3492
|
-
}
|
|
3493
|
-
console.log(response.downloadPath ? `Download path set to ${response.downloadPath}` : 'Download path set');
|
|
3494
|
-
});
|
|
3495
|
-
browser
|
|
3496
|
-
.command('sessions')
|
|
3497
|
-
.description('Browse a profile\'s captured screenshots, PDFs, recordings, and downloads, grouped by task')
|
|
3498
|
-
.option('--profile <name>', 'Only this profile (default: all profiles with captures)')
|
|
3499
|
-
.option('--open [selector]', "Open a capture in the OS default app: 'latest' or a filename")
|
|
3500
|
-
.option('--json', 'Emit machine-readable JSON')
|
|
3501
|
-
.option('--no-interactive', 'Print the flat listing instead of opening the interactive task browser')
|
|
3502
|
-
.action(async (opts) => {
|
|
3503
|
-
await runBrowserSessionsCommand({ profile: opts.profile, open: opts.open, json: opts.json, interactive: opts.interactive });
|
|
3504
|
-
});
|
|
3505
|
-
// ─── Recording ─────────────────────────────────────────────────────────────
|
|
3506
|
-
const record = browser.command('record').description('Record a video of the page');
|
|
3507
|
-
record
|
|
3508
|
-
.command('start')
|
|
3509
|
-
.description('Start recording — auto-saved under sessions/<task>/recordings/. Bounded by --fps, --duration, --max-mb.')
|
|
3510
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3511
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3512
|
-
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
3513
|
-
.option('--fps <n>', 'Frames per second (1–30, default 5)', (v) => parseInt(v, 10))
|
|
3514
|
-
.option('--duration <sec>', 'Hard duration cap in seconds (default 60)', (v) => parseInt(v, 10))
|
|
3515
|
-
.option('--max-mb <mb>', 'Stop when output exceeds this many MB (default 25)', (v) => parseInt(v, 10))
|
|
3516
|
-
.action(async (opts) => {
|
|
3517
|
-
const task = resolveTaskName(opts);
|
|
3518
|
-
const response = await sendIPCRequest({
|
|
3519
|
-
action: 'record-start',
|
|
3520
|
-
task,
|
|
3521
|
-
tabId: opts.tab,
|
|
3522
|
-
fps: opts.fps,
|
|
3523
|
-
duration: opts.duration,
|
|
3524
|
-
maxMb: opts.maxMb,
|
|
3525
|
-
});
|
|
3526
|
-
if (!response.ok) {
|
|
3527
|
-
console.error(response.error);
|
|
3528
|
-
process.exit(1);
|
|
3529
|
-
}
|
|
3530
|
-
// stdout: path (for capture into a variable). stderr: human commentary.
|
|
3531
|
-
console.log(response.path);
|
|
3532
|
-
console.error(`Recording task "${task}" at ${response.fps} fps (cap ${response.durationCapSec}s / ${response.maxMb} MB) → ${response.path}`);
|
|
3533
|
-
console.error('Stop with: agents browser record stop');
|
|
3534
|
-
});
|
|
3535
|
-
record
|
|
3536
|
-
.command('stop')
|
|
3537
|
-
.description('Stop an in-progress recording')
|
|
3538
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3539
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3540
|
-
.action(async (opts) => {
|
|
3541
|
-
const task = resolveTaskName(opts);
|
|
3542
|
-
const response = await sendIPCRequest({ action: 'record-stop', task });
|
|
3543
|
-
if (!response.ok) {
|
|
3544
|
-
console.error(response.error);
|
|
3545
|
-
process.exit(1);
|
|
3546
|
-
}
|
|
3547
|
-
console.log(response.path);
|
|
3548
|
-
const size = humanizeBytes(response.bytes);
|
|
3549
|
-
const seconds = ((response.durationMs ?? 0) / 1000).toFixed(1);
|
|
3550
|
-
console.error(`Saved recording to ${response.path} (${size}, ${seconds}s, stopped: ${response.stopReason})`);
|
|
3551
|
-
});
|
|
3552
|
-
browser
|
|
3553
|
-
.command('waitdownload')
|
|
3554
|
-
.description('Wait for a download to complete')
|
|
3555
|
-
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
3556
|
-
.option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
|
|
3557
|
-
.option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
|
|
3558
|
-
.action(async (opts) => {
|
|
3559
|
-
const task = resolveTaskName(opts);
|
|
3560
|
-
const response = await sendIPCRequest({
|
|
3561
|
-
action: 'wait-download',
|
|
3562
|
-
task,
|
|
3563
|
-
timeout: opts.timeout,
|
|
3564
|
-
});
|
|
3565
|
-
if (!response.ok) {
|
|
3566
|
-
console.error(response.error);
|
|
3567
|
-
process.exit(1);
|
|
3568
|
-
}
|
|
3569
|
-
console.log(`Downloaded: ${response.downloadPath}`);
|
|
3570
|
-
});
|
|
3571
|
-
}
|
|
3572
|
-
function collect(val, memo) {
|
|
3573
|
-
memo.push(val);
|
|
3574
|
-
return memo;
|
|
3575
|
-
}
|
|
3576
|
-
function formatAge(timestamp) {
|
|
3577
|
-
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
3578
|
-
if (seconds < 60)
|
|
3579
|
-
return `${seconds}s ago`;
|
|
3580
|
-
const minutes = Math.floor(seconds / 60);
|
|
3581
|
-
if (minutes < 60)
|
|
3582
|
-
return `${minutes}m ago`;
|
|
3583
|
-
const hours = Math.floor(minutes / 60);
|
|
3584
|
-
return `${hours}h ago`;
|
|
3585
|
-
}
|
|
3586
|
-
function humanizeBytes(n) {
|
|
3587
|
-
if (n === undefined)
|
|
3588
|
-
return 'unknown size';
|
|
3589
|
-
if (n < 1024)
|
|
3590
|
-
return `${n} B`;
|
|
3591
|
-
if (n < 1024 * 1024)
|
|
3592
|
-
return `${(n / 1024).toFixed(0)} KB`;
|
|
3593
|
-
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
3594
|
-
}
|
|
3595
|
-
function formatDuration(ms) {
|
|
3596
|
-
const seconds = Math.floor(ms / 1000);
|
|
3597
|
-
if (seconds < 60)
|
|
3598
|
-
return `${seconds}s`;
|
|
3599
|
-
const minutes = Math.floor(seconds / 60);
|
|
3600
|
-
if (minutes < 60)
|
|
3601
|
-
return `${minutes}m`;
|
|
3602
|
-
const hours = Math.floor(minutes / 60);
|
|
3603
|
-
const mm = minutes % 60;
|
|
3604
|
-
return mm ? `${hours}h ${mm}m` : `${hours}h`;
|
|
3605
235
|
}
|