@phnx-labs/agents-cli 1.20.62 → 1.20.63
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 +45 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +41 -0
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +73 -16
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +8 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +72 -2
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +6 -2
- package/dist/lib/agents.d.ts +26 -0
- package/dist/lib/agents.js +58 -18
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- package/dist/lib/session/sync/agents.d.ts +2 -0
- package/dist/lib/session/sync/agents.js +39 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +3 -0
- package/dist/lib/session/sync/sync.js +26 -6
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +11 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
|
|
|
50
50
|
// module on each invocation (which loaded the whole ~50-module tree before the
|
|
51
51
|
// first byte of output), the registry maps a command name to a thunk that
|
|
52
52
|
// imports only what that command needs. See src/lib/startup/command-registry.ts.
|
|
53
|
-
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } from './lib/startup/command-registry.js';
|
|
53
|
+
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } from './lib/startup/command-registry.js';
|
|
54
54
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
55
55
|
import { renderWhatsNew } from './lib/whats-new.js';
|
|
56
56
|
import { emit, redactArgs } from './lib/events.js';
|
|
@@ -174,6 +174,8 @@ Run and dispatch:
|
|
|
174
174
|
defaults Configure run defaults by agent/version selector
|
|
175
175
|
teams Coordinate multiple agents on shared work
|
|
176
176
|
routines Run agents on a cron schedule (scheduler auto-starts)
|
|
177
|
+
webhook Receive signed GitHub/Linear webhooks for trigger routines
|
|
178
|
+
funnel Expose a webhook receiver through Tailscale Funnel
|
|
177
179
|
sessions Browse, search, and replay past runs (live-search in TTY; grouped by workspace)
|
|
178
180
|
logs [id] Show a run's log — host-dispatch task or session; -f to follow
|
|
179
181
|
browser Automate a browser — navigate, click, screenshot, console, network
|
|
@@ -702,6 +704,8 @@ async function registerAllEagerCommands() {
|
|
|
702
704
|
await reg(loadLogs);
|
|
703
705
|
await reg(loadEvents);
|
|
704
706
|
await reg(loadAudit);
|
|
707
|
+
await reg(loadWebhook);
|
|
708
|
+
await reg(loadFunnel);
|
|
705
709
|
await reg(loadFeed);
|
|
706
710
|
await reg(loadSsh);
|
|
707
711
|
registerJobsCronAliasCommand(program, 'jobs');
|
|
@@ -875,7 +879,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
|
|
|
875
879
|
// Bumping the suffix re-runs migrations for every user; binary releases that
|
|
876
880
|
// don't change the schema must NOT re-run (they would destroy user content
|
|
877
881
|
// when migration steps overlap with user-authored paths). See issue #20.
|
|
878
|
-
const sentinelValue = '
|
|
882
|
+
const sentinelValue = 'v13';
|
|
879
883
|
let needRun = true;
|
|
880
884
|
try {
|
|
881
885
|
if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -130,6 +130,32 @@ export interface AccountInfo {
|
|
|
130
130
|
}
|
|
131
131
|
/** Return the email address associated with the agent's auth config, or null. */
|
|
132
132
|
export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
|
|
133
|
+
/**
|
|
134
|
+
* Antigravity (`agy`) stores its OAuth token via the Go keyring library
|
|
135
|
+
* (zalando/go-keyring), which is platform-split:
|
|
136
|
+
*
|
|
137
|
+
* - macOS: login keychain, service `gemini`, account `antigravity` — no file.
|
|
138
|
+
* - Linux with Secret Service (libsecret / gnome-keyring): attributes
|
|
139
|
+
* service=`gemini`, username=`antigravity` (go-keyring's Secret Service
|
|
140
|
+
* mapping of service+user). Prefer this over the file when a keyring
|
|
141
|
+
* daemon is running.
|
|
142
|
+
* - Linux without Secret Service: file fallback at
|
|
143
|
+
* `~/.gemini/antigravity-cli/antigravity-oauth-token`.
|
|
144
|
+
*
|
|
145
|
+
* Probe the OS keyring for existence after the file check. On macOS,
|
|
146
|
+
* `security find-generic-password` without `-w` is metadata-only (never
|
|
147
|
+
* prompts). On Linux, `secret-tool lookup` exit 0 means the item exists
|
|
148
|
+
* (stdout is the secret — discarded, never logged). Cached per process —
|
|
149
|
+
* the keyring is account-global, so one probe covers every installed version.
|
|
150
|
+
* Returns false when the platform has no probe (Windows) or the tool is
|
|
151
|
+
* missing. Guard with `AGENTS_NO_KEYCHAIN_PROBE=1` for hermetic tests.
|
|
152
|
+
*/
|
|
153
|
+
export declare function antigravityOsKeyringProbe(platform?: NodeJS.Platform): {
|
|
154
|
+
cmd: string;
|
|
155
|
+
args: string[];
|
|
156
|
+
} | null;
|
|
157
|
+
/** @internal test hook — clear the per-process keyring probe cache. */
|
|
158
|
+
export declare function __resetAntigravityKeychainCacheForTest(): void;
|
|
133
159
|
export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
|
|
134
160
|
/**
|
|
135
161
|
* Determine when the agent was last used by checking session file mtimes,
|
package/dist/lib/agents.js
CHANGED
|
@@ -1027,31 +1027,69 @@ function decryptDroidCredential(base) {
|
|
|
1027
1027
|
}
|
|
1028
1028
|
let cachedAgyKeychainSignedIn;
|
|
1029
1029
|
/**
|
|
1030
|
-
* Antigravity (`agy
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1030
|
+
* Antigravity (`agy`) stores its OAuth token via the Go keyring library
|
|
1031
|
+
* (zalando/go-keyring), which is platform-split:
|
|
1032
|
+
*
|
|
1033
|
+
* - macOS: login keychain, service `gemini`, account `antigravity` — no file.
|
|
1034
|
+
* - Linux with Secret Service (libsecret / gnome-keyring): attributes
|
|
1035
|
+
* service=`gemini`, username=`antigravity` (go-keyring's Secret Service
|
|
1036
|
+
* mapping of service+user). Prefer this over the file when a keyring
|
|
1037
|
+
* daemon is running.
|
|
1038
|
+
* - Linux without Secret Service: file fallback at
|
|
1039
|
+
* `~/.gemini/antigravity-cli/antigravity-oauth-token`.
|
|
1040
|
+
*
|
|
1041
|
+
* Probe the OS keyring for existence after the file check. On macOS,
|
|
1042
|
+
* `security find-generic-password` without `-w` is metadata-only (never
|
|
1043
|
+
* prompts). On Linux, `secret-tool lookup` exit 0 means the item exists
|
|
1044
|
+
* (stdout is the secret — discarded, never logged). Cached per process —
|
|
1045
|
+
* the keyring is account-global, so one probe covers every installed version.
|
|
1046
|
+
* Returns false when the platform has no probe (Windows) or the tool is
|
|
1047
|
+
* missing. Guard with `AGENTS_NO_KEYCHAIN_PROBE=1` for hermetic tests.
|
|
1037
1048
|
*/
|
|
1049
|
+
export function antigravityOsKeyringProbe(platform = process.platform) {
|
|
1050
|
+
if (platform === 'darwin') {
|
|
1051
|
+
return {
|
|
1052
|
+
cmd: 'security',
|
|
1053
|
+
args: ['find-generic-password', '-s', 'gemini', '-a', 'antigravity'],
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
if (platform === 'linux') {
|
|
1057
|
+
// go-keyring secret_service attributes: "service" + "username" (not
|
|
1058
|
+
// "account" — that flag is the macOS security(1) spelling of the same user).
|
|
1059
|
+
return {
|
|
1060
|
+
cmd: 'secret-tool',
|
|
1061
|
+
args: ['lookup', 'service', 'gemini', 'username', 'antigravity'],
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
/** @internal test hook — clear the per-process keyring probe cache. */
|
|
1067
|
+
export function __resetAntigravityKeychainCacheForTest() {
|
|
1068
|
+
cachedAgyKeychainSignedIn = undefined;
|
|
1069
|
+
}
|
|
1038
1070
|
async function antigravityKeychainSignedIn() {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
// Test isolation: the real macOS keychain can't be sandboxed per-test, so
|
|
1042
|
-
// allow suites asserting "signed out" to opt out of the probe (same spirit as
|
|
1043
|
-
// AGENTS_REAL_HOME). Not cached, so tests can toggle it.
|
|
1071
|
+
// Test isolation first (before cache): real OS keyrings can't be sandboxed
|
|
1072
|
+
// per-test. Same spirit as AGENTS_REAL_HOME. Not cached, so tests can toggle.
|
|
1044
1073
|
if (process.env.AGENTS_NO_KEYCHAIN_PROBE === '1')
|
|
1045
1074
|
return false;
|
|
1046
|
-
if (
|
|
1075
|
+
if (cachedAgyKeychainSignedIn !== undefined)
|
|
1076
|
+
return cachedAgyKeychainSignedIn;
|
|
1077
|
+
const probe = antigravityOsKeyringProbe();
|
|
1078
|
+
if (!probe) {
|
|
1047
1079
|
cachedAgyKeychainSignedIn = false;
|
|
1048
1080
|
return false;
|
|
1049
1081
|
}
|
|
1050
1082
|
try {
|
|
1051
|
-
|
|
1083
|
+
// Discard stdout: Linux secret-tool lookup prints the secret value.
|
|
1084
|
+
await execFileAsync(probe.cmd, probe.args, {
|
|
1085
|
+
timeout: 3000,
|
|
1086
|
+
// encoding so stdout is a string we can drop without ever logging it
|
|
1087
|
+
encoding: 'utf8',
|
|
1088
|
+
});
|
|
1052
1089
|
cachedAgyKeychainSignedIn = true;
|
|
1053
1090
|
}
|
|
1054
1091
|
catch {
|
|
1092
|
+
// Missing tool (ENOENT), missing item, locked collection, timeout → signed out.
|
|
1055
1093
|
cachedAgyKeychainSignedIn = false;
|
|
1056
1094
|
}
|
|
1057
1095
|
return cachedAgyKeychainSignedIn;
|
|
@@ -1282,10 +1320,12 @@ export async function getAccountInfo(agentId, home) {
|
|
|
1282
1320
|
// Antigravity (`agy`) stores a consumer Google OAuth grant (access +
|
|
1283
1321
|
// refresh token, no id_token) — presence of a refresh token is the only
|
|
1284
1322
|
// signed-in signal we can derive without a network call. Storage is
|
|
1285
|
-
// platform-split
|
|
1286
|
-
// ~/.gemini/antigravity-cli/antigravity-oauth-token
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
1323
|
+
// platform-split via go-keyring:
|
|
1324
|
+
// - file ~/.gemini/antigravity-cli/antigravity-oauth-token (Linux
|
|
1325
|
+
// fallback when no Secret Service is available)
|
|
1326
|
+
// - macOS keychain / Linux libsecret (service gemini + user
|
|
1327
|
+
// antigravity) when a keyring daemon is present
|
|
1328
|
+
// Check the file first, then the OS keyring probe.
|
|
1289
1329
|
const tokenPath = resolveAccountCredentialPath(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
|
|
1290
1330
|
if (tokenPath) {
|
|
1291
1331
|
const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -432,11 +432,12 @@ export class BrowserIPCServer {
|
|
|
432
432
|
return { ok: true };
|
|
433
433
|
}
|
|
434
434
|
case 'set-download-path': {
|
|
435
|
-
if (!request.task
|
|
436
|
-
return { ok: false, error: 'Task
|
|
435
|
+
if (!request.task) {
|
|
436
|
+
return { ok: false, error: 'Task required' };
|
|
437
437
|
}
|
|
438
|
-
|
|
439
|
-
|
|
438
|
+
// downloadPath optional: omitted → service defaults to the profile's downloads dir.
|
|
439
|
+
const resolved = await this.service.setDownloadPath(request.task, request.downloadPath, request.tabId);
|
|
440
|
+
return { ok: true, downloadPath: resolved };
|
|
440
441
|
}
|
|
441
442
|
case 'wait-download': {
|
|
442
443
|
if (!request.task) {
|
|
@@ -11,6 +11,19 @@ export declare const DEFAULT_BROWSER_PROFILE_NAME = "default";
|
|
|
11
11
|
export declare function getConfiguredDefaultProfileName(): string | undefined;
|
|
12
12
|
export declare function getBrowserRuntimeDir(): string;
|
|
13
13
|
export declare function getProfileRuntimeDir(name: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Default destination for browser downloads for a profile. Set browser-global at
|
|
16
|
+
* connect time (see BrowserService), so downloads land here even when the agent
|
|
17
|
+
* never calls `browser download --path`. Keyed by the same composite name as the
|
|
18
|
+
* runtime dir, so every profile is one self-contained tree.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getProfileDownloadsDir(name: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Per-profile directory for a task's captured artifacts (screenshots, PDFs,
|
|
23
|
+
* recordings). Replaces the legacy global `browser/sessions/<task>/` root — the
|
|
24
|
+
* one-shot migration in migrate.ts folds old captures into here.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getProfileSessionsDir(name: string, task: string): string;
|
|
14
27
|
export declare function listProfiles(): Promise<BrowserProfile[]>;
|
|
15
28
|
export declare function getProfile(name: string): Promise<BrowserProfile | null>;
|
|
16
29
|
/**
|
|
@@ -19,6 +19,23 @@ export function getBrowserRuntimeDir() {
|
|
|
19
19
|
export function getProfileRuntimeDir(name) {
|
|
20
20
|
return path.join(getBrowserRuntimeDir(), name);
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Default destination for browser downloads for a profile. Set browser-global at
|
|
24
|
+
* connect time (see BrowserService), so downloads land here even when the agent
|
|
25
|
+
* never calls `browser download --path`. Keyed by the same composite name as the
|
|
26
|
+
* runtime dir, so every profile is one self-contained tree.
|
|
27
|
+
*/
|
|
28
|
+
export function getProfileDownloadsDir(name) {
|
|
29
|
+
return path.join(getProfileRuntimeDir(name), 'downloads');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Per-profile directory for a task's captured artifacts (screenshots, PDFs,
|
|
33
|
+
* recordings). Replaces the legacy global `browser/sessions/<task>/` root — the
|
|
34
|
+
* one-shot migration in migrate.ts folds old captures into here.
|
|
35
|
+
*/
|
|
36
|
+
export function getProfileSessionsDir(name, task) {
|
|
37
|
+
return path.join(getProfileRuntimeDir(name), 'sessions', task);
|
|
38
|
+
}
|
|
22
39
|
function configToProfile(name, config) {
|
|
23
40
|
validateRemoteBrowserBinaries(config);
|
|
24
41
|
return {
|
|
@@ -226,7 +226,18 @@ export declare class BrowserService {
|
|
|
226
226
|
timeout?: number;
|
|
227
227
|
tabHint?: string;
|
|
228
228
|
}): Promise<void>;
|
|
229
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Point the browser's default download destination at the profile's downloads
|
|
231
|
+
* dir, browser-global, at connect time. Without this a download the agent never
|
|
232
|
+
* explicitly routed (`browser download --path`) falls to Chromium's own default
|
|
233
|
+
* — for an attached user browser, wherever that browser was last configured,
|
|
234
|
+
* which is how downloads used to escape into random locations. Sent on the root
|
|
235
|
+
* session (no sessionId) so every current and future tab inherits it. Best
|
|
236
|
+
* effort: a remote CDP endpoint that doesn't expose the Browser domain must not
|
|
237
|
+
* fail the whole connect.
|
|
238
|
+
*/
|
|
239
|
+
private applyDefaultDownloadBehavior;
|
|
240
|
+
setDownloadPath(taskId: string, downloadPath?: string, tabHint?: string): Promise<string>;
|
|
230
241
|
waitForDownload(taskId: string, timeout?: number): Promise<string>;
|
|
231
242
|
private findTaskBySession;
|
|
232
243
|
shutdown(): Promise<void>;
|
|
@@ -4,7 +4,7 @@ import * as path from 'path';
|
|
|
4
4
|
import { execFile } from 'child_process';
|
|
5
5
|
import { promisify } from 'util';
|
|
6
6
|
import { BrowserCdpConnectionError, CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity, } from './cdp.js';
|
|
7
|
-
import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, } from './profiles.js';
|
|
7
|
+
import { getProfile, getProfileRuntimeDir, getProfileDownloadsDir, getProfileSessionsDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, } from './profiles.js';
|
|
8
8
|
import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
|
|
9
9
|
import { connectLocal } from './drivers/local.js';
|
|
10
10
|
import { connectSSH, shellQuote } from './drivers/ssh.js';
|
|
@@ -308,7 +308,9 @@ export class BrowserService {
|
|
|
308
308
|
}
|
|
309
309
|
else if (!conn) {
|
|
310
310
|
conn = await this.connectProfile(effectiveProfile, resolved.target);
|
|
311
|
+
conn.profileName = composite;
|
|
311
312
|
this.connections.set(composite, conn);
|
|
313
|
+
await this.applyDefaultDownloadBehavior(conn, composite);
|
|
312
314
|
}
|
|
313
315
|
// Browsers launch with --no-startup-window (session-cookie persistence,
|
|
314
316
|
// see launchBrowser), so a bare `start` with no --url would otherwise
|
|
@@ -645,7 +647,7 @@ export class BrowserService {
|
|
|
645
647
|
return result.result.value;
|
|
646
648
|
}
|
|
647
649
|
async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
|
|
648
|
-
const { conn, task } = await this.findTask(taskId);
|
|
650
|
+
const { conn, task, profileName } = await this.findTask(taskId);
|
|
649
651
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
650
652
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
651
653
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -678,7 +680,7 @@ export class BrowserService {
|
|
|
678
680
|
}
|
|
679
681
|
extension = 'jpg';
|
|
680
682
|
}
|
|
681
|
-
const sessionsDir =
|
|
683
|
+
const sessionsDir = getProfileSessionsDir(profileName, task.name);
|
|
682
684
|
const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
|
|
683
685
|
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
684
686
|
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
@@ -695,7 +697,7 @@ export class BrowserService {
|
|
|
695
697
|
* — without it, dark-mode pages render on a blank sheet.
|
|
696
698
|
*/
|
|
697
699
|
async printToPdf(taskId, tabHint, outputPath) {
|
|
698
|
-
const { conn, task } = await this.findTask(taskId);
|
|
700
|
+
const { conn, task, profileName } = await this.findTask(taskId);
|
|
699
701
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
700
702
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
701
703
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -705,7 +707,7 @@ export class BrowserService {
|
|
|
705
707
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
706
708
|
const { data } = (await conn.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true }, sessionId));
|
|
707
709
|
const buffer = Buffer.from(data, 'base64');
|
|
708
|
-
const sessionsDir =
|
|
710
|
+
const sessionsDir = getProfileSessionsDir(profileName, task.name);
|
|
709
711
|
const automaticPath = path.join(sessionsDir, `${Date.now()}.pdf`);
|
|
710
712
|
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
711
713
|
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
@@ -723,7 +725,7 @@ export class BrowserService {
|
|
|
723
725
|
if (this.recordings.has(taskId)) {
|
|
724
726
|
throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
|
|
725
727
|
}
|
|
726
|
-
const { conn, task } = await this.findTask(taskId);
|
|
728
|
+
const { conn, task, profileName } = await this.findTask(taskId);
|
|
727
729
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
728
730
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
729
731
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
@@ -739,7 +741,7 @@ export class BrowserService {
|
|
|
739
741
|
throw new Error('--duration must be between 1 and 3600 seconds');
|
|
740
742
|
if (maxMb < 1 || maxMb > 500)
|
|
741
743
|
throw new Error('--max-mb must be between 1 and 500');
|
|
742
|
-
const recordingsDir = path.join(
|
|
744
|
+
const recordingsDir = path.join(getProfileSessionsDir(profileName, task.name), 'recordings');
|
|
743
745
|
await fs.promises.mkdir(recordingsDir, { recursive: true });
|
|
744
746
|
const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
|
|
745
747
|
// Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
|
|
@@ -1481,20 +1483,50 @@ export class BrowserService {
|
|
|
1481
1483
|
throw new Error(`Wait condition "${type}:${value}" not met within ${timeout}ms`);
|
|
1482
1484
|
}
|
|
1483
1485
|
// ─── Downloads ───────────────────────────────────────────────────────────────
|
|
1486
|
+
/**
|
|
1487
|
+
* Point the browser's default download destination at the profile's downloads
|
|
1488
|
+
* dir, browser-global, at connect time. Without this a download the agent never
|
|
1489
|
+
* explicitly routed (`browser download --path`) falls to Chromium's own default
|
|
1490
|
+
* — for an attached user browser, wherever that browser was last configured,
|
|
1491
|
+
* which is how downloads used to escape into random locations. Sent on the root
|
|
1492
|
+
* session (no sessionId) so every current and future tab inherits it. Best
|
|
1493
|
+
* effort: a remote CDP endpoint that doesn't expose the Browser domain must not
|
|
1494
|
+
* fail the whole connect.
|
|
1495
|
+
*/
|
|
1496
|
+
async applyDefaultDownloadBehavior(conn, profileName) {
|
|
1497
|
+
const downloadPath = getProfileDownloadsDir(profileName);
|
|
1498
|
+
try {
|
|
1499
|
+
await fs.promises.mkdir(downloadPath, { recursive: true });
|
|
1500
|
+
await conn.cdp.send('Browser.setDownloadBehavior', {
|
|
1501
|
+
behavior: 'allow',
|
|
1502
|
+
downloadPath,
|
|
1503
|
+
eventsEnabled: true,
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
catch {
|
|
1507
|
+
// Best effort: a remote CDP endpoint (ssh://, ws(s)://) may not expose the
|
|
1508
|
+
// Browser domain. Downloads then keep the endpoint's own default; the connect
|
|
1509
|
+
// must still succeed.
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1484
1512
|
async setDownloadPath(taskId, downloadPath, tabHint) {
|
|
1485
|
-
const { conn, task } = await this.findTask(taskId);
|
|
1513
|
+
const { conn, task, profileName } = await this.findTask(taskId);
|
|
1486
1514
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1487
1515
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1488
1516
|
const target = await this.getTarget(conn, cdpTargetId);
|
|
1489
1517
|
if (!target)
|
|
1490
1518
|
throw new Error(`Tab ${shortId} not found`);
|
|
1491
1519
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
1520
|
+
// No explicit --path: fall back to the profile's downloads dir, the same
|
|
1521
|
+
// destination already set browser-global at connect (applyDefaultDownloadBehavior).
|
|
1522
|
+
const resolvedPath = downloadPath ?? getProfileDownloadsDir(profileName);
|
|
1523
|
+
await fs.promises.mkdir(resolvedPath, { recursive: true });
|
|
1492
1524
|
await conn.cdp.send('Browser.setDownloadBehavior', {
|
|
1493
1525
|
behavior: 'allow',
|
|
1494
|
-
downloadPath,
|
|
1526
|
+
downloadPath: resolvedPath,
|
|
1495
1527
|
eventsEnabled: true,
|
|
1496
1528
|
}, sessionId);
|
|
1497
|
-
this.pendingDownloads.set(taskId, { path:
|
|
1529
|
+
this.pendingDownloads.set(taskId, { path: resolvedPath, completed: false });
|
|
1498
1530
|
conn.cdp.on('Browser.downloadProgress', (params) => {
|
|
1499
1531
|
if (params.state === 'completed') {
|
|
1500
1532
|
const dl = this.pendingDownloads.get(taskId);
|
|
@@ -1504,6 +1536,7 @@ export class BrowserService {
|
|
|
1504
1536
|
}
|
|
1505
1537
|
}
|
|
1506
1538
|
});
|
|
1539
|
+
return resolvedPath;
|
|
1507
1540
|
}
|
|
1508
1541
|
async waitForDownload(taskId, timeout = 60000) {
|
|
1509
1542
|
const start = Date.now();
|
|
@@ -1590,11 +1623,13 @@ export class BrowserService {
|
|
|
1590
1623
|
pid,
|
|
1591
1624
|
electron: true,
|
|
1592
1625
|
targetFilter: profile.targetFilter,
|
|
1626
|
+
profileName: forkName,
|
|
1593
1627
|
forkedFrom: profile.name,
|
|
1594
1628
|
tasks: new Map(),
|
|
1595
1629
|
sessionCache: new Map(),
|
|
1596
1630
|
};
|
|
1597
1631
|
this.connections.set(forkName, connection);
|
|
1632
|
+
await this.applyDefaultDownloadBehavior(connection, forkName);
|
|
1598
1633
|
return { forkName, connection };
|
|
1599
1634
|
}
|
|
1600
1635
|
/**
|
|
@@ -1776,12 +1811,12 @@ export class BrowserService {
|
|
|
1776
1811
|
if (!task) {
|
|
1777
1812
|
throw new Error(`Task "${taskId}" not found on profile "${profileName}"`);
|
|
1778
1813
|
}
|
|
1779
|
-
return { conn, task };
|
|
1814
|
+
return { conn, task, profileName };
|
|
1780
1815
|
}
|
|
1781
|
-
for (const [, conn] of this.connections) {
|
|
1816
|
+
for (const [key, conn] of this.connections) {
|
|
1782
1817
|
const task = conn.tasks.get(taskId);
|
|
1783
1818
|
if (task) {
|
|
1784
|
-
return { conn, task };
|
|
1819
|
+
return { conn, task, profileName: conn.profileName ?? key };
|
|
1785
1820
|
}
|
|
1786
1821
|
}
|
|
1787
1822
|
throw new Error(`Task "${taskId}" not found`);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type ArtifactKind = 'screenshot' | 'pdf' | 'recording' | 'download';
|
|
2
|
+
export interface BrowserArtifact {
|
|
3
|
+
kind: ArtifactKind;
|
|
4
|
+
/** Owning task for session captures; undefined for downloads. */
|
|
5
|
+
task?: string;
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
bytes: number;
|
|
9
|
+
mtimeMs: number;
|
|
10
|
+
}
|
|
11
|
+
export interface ProfileArtifacts {
|
|
12
|
+
profile: string;
|
|
13
|
+
artifacts: BrowserArtifact[];
|
|
14
|
+
}
|
|
15
|
+
/** Every capture for one profile, newest first. */
|
|
16
|
+
export declare function listProfileArtifacts(profile: string): BrowserArtifact[];
|
|
17
|
+
/**
|
|
18
|
+
* Captures grouped by profile. With `only` set, returns just that profile (even
|
|
19
|
+
* when empty); otherwise every profile dir on disk that has at least one capture.
|
|
20
|
+
*/
|
|
21
|
+
export declare function listBrowserSessions(only?: string): ProfileArtifacts[];
|
|
22
|
+
/** Human table for the CLI. Returns lines (no trailing newline). */
|
|
23
|
+
export declare function renderBrowserSessions(groups: ProfileArtifacts[]): string;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve `--open <sel>`: `latest` (newest across the groups) or a filename
|
|
26
|
+
* substring match. Returns the absolute path, or null if nothing matched.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveArtifact(groups: ProfileArtifacts[], selector: string): string | null;
|
|
29
|
+
/** Open a file in the OS default app. Returns true on success. */
|
|
30
|
+
export declare function openArtifact(filePath: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Shared CLI action for `agents browser sessions` and `agents sessions --browser`.
|
|
33
|
+
* `open` is the Commander value for `--open [selector]`: undefined when the flag
|
|
34
|
+
* is absent, `true` when passed bare (defaults to 'latest'), or the selector string.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runBrowserSessions(opts: {
|
|
37
|
+
profile?: string;
|
|
38
|
+
open?: string | boolean;
|
|
39
|
+
json?: boolean;
|
|
40
|
+
}): void;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only listing of a browser profile's on-disk captures — screenshots, PDFs,
|
|
3
|
+
* recordings (`<profile>/sessions/<task>/`) and downloads (`<profile>/downloads/`).
|
|
4
|
+
* Reads straight from `.cache/browser/<profile>/`, so it works whether or not the
|
|
5
|
+
* browser daemon is running. Backs both `agents browser sessions` and the
|
|
6
|
+
* `agents sessions --browser` alias.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
import { getBrowserRuntimeDir, getProfileRuntimeDir } from './profiles.js';
|
|
12
|
+
import { formatRelativeTime } from '../session/relative-time.js';
|
|
13
|
+
const EXT_KIND = {
|
|
14
|
+
'.png': 'screenshot',
|
|
15
|
+
'.jpg': 'screenshot',
|
|
16
|
+
'.jpeg': 'screenshot',
|
|
17
|
+
'.webp': 'screenshot',
|
|
18
|
+
'.pdf': 'pdf',
|
|
19
|
+
'.webm': 'recording',
|
|
20
|
+
};
|
|
21
|
+
function statSafe(p) {
|
|
22
|
+
try {
|
|
23
|
+
return fs.statSync(p);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function walkFiles(dir) {
|
|
30
|
+
let out = [];
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
const full = path.join(dir, e.name);
|
|
40
|
+
if (e.isDirectory())
|
|
41
|
+
out = out.concat(walkFiles(full));
|
|
42
|
+
else if (e.isFile())
|
|
43
|
+
out.push(full);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** Every capture for one profile, newest first. */
|
|
48
|
+
export function listProfileArtifacts(profile) {
|
|
49
|
+
const root = getProfileRuntimeDir(profile);
|
|
50
|
+
const artifacts = [];
|
|
51
|
+
const sessionsRoot = path.join(root, 'sessions');
|
|
52
|
+
let taskDirs = [];
|
|
53
|
+
try {
|
|
54
|
+
taskDirs = fs.readdirSync(sessionsRoot, { withFileTypes: true });
|
|
55
|
+
}
|
|
56
|
+
catch { /* none */ }
|
|
57
|
+
for (const t of taskDirs) {
|
|
58
|
+
if (!t.isDirectory())
|
|
59
|
+
continue;
|
|
60
|
+
for (const file of walkFiles(path.join(sessionsRoot, t.name))) {
|
|
61
|
+
const kind = EXT_KIND[path.extname(file).toLowerCase()];
|
|
62
|
+
if (!kind)
|
|
63
|
+
continue;
|
|
64
|
+
const st = statSafe(file);
|
|
65
|
+
if (!st)
|
|
66
|
+
continue;
|
|
67
|
+
artifacts.push({ kind, task: t.name, name: path.basename(file), path: file, bytes: st.size, mtimeMs: st.mtimeMs });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const file of walkFiles(path.join(root, 'downloads'))) {
|
|
71
|
+
const st = statSafe(file);
|
|
72
|
+
if (!st)
|
|
73
|
+
continue;
|
|
74
|
+
artifacts.push({ kind: 'download', name: path.basename(file), path: file, bytes: st.size, mtimeMs: st.mtimeMs });
|
|
75
|
+
}
|
|
76
|
+
artifacts.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
77
|
+
return artifacts;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Captures grouped by profile. With `only` set, returns just that profile (even
|
|
81
|
+
* when empty); otherwise every profile dir on disk that has at least one capture.
|
|
82
|
+
*/
|
|
83
|
+
export function listBrowserSessions(only) {
|
|
84
|
+
let profiles;
|
|
85
|
+
if (only) {
|
|
86
|
+
profiles = [only];
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
try {
|
|
90
|
+
profiles = fs.readdirSync(getBrowserRuntimeDir(), { withFileTypes: true })
|
|
91
|
+
.filter((e) => e.isDirectory() && e.name !== 'sessions')
|
|
92
|
+
.map((e) => e.name)
|
|
93
|
+
.sort();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
profiles = [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return profiles
|
|
100
|
+
.map((p) => ({ profile: p, artifacts: listProfileArtifacts(p) }))
|
|
101
|
+
.filter((r) => !!only || r.artifacts.length > 0);
|
|
102
|
+
}
|
|
103
|
+
function formatBytes(n) {
|
|
104
|
+
if (n < 1024)
|
|
105
|
+
return `${n} B`;
|
|
106
|
+
const units = ['KB', 'MB', 'GB'];
|
|
107
|
+
let val = n / 1024;
|
|
108
|
+
let i = 0;
|
|
109
|
+
while (val >= 1024 && i < units.length - 1) {
|
|
110
|
+
val /= 1024;
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
|
|
114
|
+
}
|
|
115
|
+
/** Human table for the CLI. Returns lines (no trailing newline). */
|
|
116
|
+
export function renderBrowserSessions(groups) {
|
|
117
|
+
if (groups.length === 0)
|
|
118
|
+
return 'No browser profiles found.';
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const g of groups) {
|
|
121
|
+
const counts = { screenshot: 0, pdf: 0, recording: 0, download: 0 };
|
|
122
|
+
for (const a of g.artifacts)
|
|
123
|
+
counts[a.kind]++;
|
|
124
|
+
lines.push(`${g.profile} ` +
|
|
125
|
+
`screenshots ${counts.screenshot} pdfs ${counts.pdf} recordings ${counts.recording} downloads ${counts.download}`);
|
|
126
|
+
if (g.artifacts.length === 0) {
|
|
127
|
+
lines.push(' (no captures yet)');
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
for (const a of g.artifacts) {
|
|
131
|
+
const when = formatRelativeTime(new Date(a.mtimeMs).toISOString());
|
|
132
|
+
const where = a.kind === 'download' ? 'downloads/' : `sessions/${a.task}/`;
|
|
133
|
+
lines.push(` ${when.padEnd(12)} ${a.name.padEnd(28)} ${formatBytes(a.bytes).padStart(8)} ${where}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resolve `--open <sel>`: `latest` (newest across the groups) or a filename
|
|
140
|
+
* substring match. Returns the absolute path, or null if nothing matched.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveArtifact(groups, selector) {
|
|
143
|
+
const all = groups.flatMap((g) => g.artifacts).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
144
|
+
if (all.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
if (selector === 'latest')
|
|
147
|
+
return all[0].path;
|
|
148
|
+
const hit = all.find((a) => a.name === selector) ?? all.find((a) => a.name.includes(selector));
|
|
149
|
+
return hit ? hit.path : null;
|
|
150
|
+
}
|
|
151
|
+
/** Open a file in the OS default app. Returns true on success. */
|
|
152
|
+
export function openArtifact(filePath) {
|
|
153
|
+
const openers = process.platform === 'darwin'
|
|
154
|
+
? [['open', [filePath]]]
|
|
155
|
+
: process.platform === 'win32'
|
|
156
|
+
? [['cmd', ['/c', 'start', '""', filePath]]]
|
|
157
|
+
: [['xdg-open', [filePath]], ['gnome-open', [filePath]]];
|
|
158
|
+
for (const [cmd, args] of openers) {
|
|
159
|
+
if (spawnSync(cmd, args, { stdio: 'ignore' }).status === 0)
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Shared CLI action for `agents browser sessions` and `agents sessions --browser`.
|
|
166
|
+
* `open` is the Commander value for `--open [selector]`: undefined when the flag
|
|
167
|
+
* is absent, `true` when passed bare (defaults to 'latest'), or the selector string.
|
|
168
|
+
*/
|
|
169
|
+
export function runBrowserSessions(opts) {
|
|
170
|
+
const groups = listBrowserSessions(opts.profile);
|
|
171
|
+
if (opts.open !== undefined && opts.open !== false) {
|
|
172
|
+
const selector = opts.open === true ? 'latest' : opts.open;
|
|
173
|
+
const target = resolveArtifact(groups, selector);
|
|
174
|
+
if (!target) {
|
|
175
|
+
console.error(`No capture matching "${selector}".`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
console.log(target);
|
|
179
|
+
if (!openArtifact(target)) {
|
|
180
|
+
console.error(`Could not open ${target}`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (opts.json) {
|
|
186
|
+
console.log(JSON.stringify(groups, null, 2));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
console.log(renderBrowserSessions(groups));
|
|
190
|
+
}
|