@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +10 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +41 -0
  6. package/dist/commands/funnel.d.ts +5 -0
  7. package/dist/commands/funnel.js +62 -0
  8. package/dist/commands/hosts.js +42 -0
  9. package/dist/commands/repo.d.ts +4 -4
  10. package/dist/commands/repo.js +30 -19
  11. package/dist/commands/routines.js +73 -16
  12. package/dist/commands/sessions-sync.d.ts +1 -0
  13. package/dist/commands/sessions-sync.js +16 -2
  14. package/dist/commands/sessions.js +8 -1
  15. package/dist/commands/setup.js +9 -0
  16. package/dist/commands/ssh.js +72 -2
  17. package/dist/commands/sync-provision.d.ts +23 -0
  18. package/dist/commands/sync-provision.js +107 -0
  19. package/dist/commands/webhook.d.ts +9 -0
  20. package/dist/commands/webhook.js +93 -0
  21. package/dist/index.js +6 -2
  22. package/dist/lib/agents.d.ts +26 -0
  23. package/dist/lib/agents.js +58 -18
  24. package/dist/lib/browser/ipc.js +5 -4
  25. package/dist/lib/browser/profiles.d.ts +13 -0
  26. package/dist/lib/browser/profiles.js +17 -0
  27. package/dist/lib/browser/service.d.ts +12 -1
  28. package/dist/lib/browser/service.js +48 -13
  29. package/dist/lib/browser/sessions-list.d.ts +40 -0
  30. package/dist/lib/browser/sessions-list.js +190 -0
  31. package/dist/lib/daemon.js +2 -0
  32. package/dist/lib/devices/fleet.d.ts +62 -0
  33. package/dist/lib/devices/fleet.js +128 -0
  34. package/dist/lib/funnel.d.ts +5 -0
  35. package/dist/lib/funnel.js +23 -0
  36. package/dist/lib/git.d.ts +21 -5
  37. package/dist/lib/git.js +64 -14
  38. package/dist/lib/hosts/credentials.d.ts +28 -0
  39. package/dist/lib/hosts/credentials.js +48 -0
  40. package/dist/lib/hosts/dispatch.d.ts +25 -0
  41. package/dist/lib/hosts/dispatch.js +68 -2
  42. package/dist/lib/hosts/passthrough.d.ts +13 -10
  43. package/dist/lib/hosts/passthrough.js +119 -29
  44. package/dist/lib/mailbox-gc.js +4 -16
  45. package/dist/lib/migrate.d.ts +12 -0
  46. package/dist/lib/migrate.js +55 -1
  47. package/dist/lib/routines.d.ts +29 -10
  48. package/dist/lib/routines.js +47 -15
  49. package/dist/lib/session/sync/agents.d.ts +2 -0
  50. package/dist/lib/session/sync/agents.js +39 -1
  51. package/dist/lib/session/sync/config.d.ts +8 -0
  52. package/dist/lib/session/sync/config.js +6 -1
  53. package/dist/lib/session/sync/provision.d.ts +49 -0
  54. package/dist/lib/session/sync/provision.js +91 -0
  55. package/dist/lib/session/sync/sync.d.ts +3 -0
  56. package/dist/lib/session/sync/sync.js +26 -6
  57. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  58. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  59. package/dist/lib/startup/command-registry.d.ts +2 -0
  60. package/dist/lib/startup/command-registry.js +11 -0
  61. package/dist/lib/state.d.ts +10 -2
  62. package/dist/lib/state.js +14 -2
  63. package/dist/lib/triggers/webhook.d.ts +70 -27
  64. package/dist/lib/triggers/webhook.js +264 -43
  65. 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 = 'v12';
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) {
@@ -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,
@@ -1027,31 +1027,69 @@ function decryptDroidCredential(base) {
1027
1027
  }
1028
1028
  let cachedAgyKeychainSignedIn;
1029
1029
  /**
1030
- * Antigravity (`agy`, a Codeium/Windsurf-based CLI) stores its OAuth token in
1031
- * the macOS keychain — service `gemini`, account `antigravity` — NOT a file.
1032
- * The file path (`antigravity-oauth-token`) only exists on Linux, where the Go
1033
- * keyring falls back to disk. Probe the keychain for existence (metadata only;
1034
- * `-w` omitted so it never prompts). Cached per process — the keychain is
1035
- * account-global, so one probe covers every installed version. Returns false on
1036
- * non-macOS (the file path handles those).
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
- if (cachedAgyKeychainSignedIn !== undefined)
1040
- return cachedAgyKeychainSignedIn;
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 (process.platform !== 'darwin') {
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
- await execFileAsync('security', ['find-generic-password', '-s', 'gemini', '-a', 'antigravity'], { timeout: 3000 });
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: on Linux it's a file at
1286
- // ~/.gemini/antigravity-cli/antigravity-oauth-token; on macOS the Go
1287
- // keyring puts it in the keychain (service 'gemini', account
1288
- // 'antigravity'), so no file exists check both.
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'));
@@ -432,11 +432,12 @@ export class BrowserIPCServer {
432
432
  return { ok: true };
433
433
  }
434
434
  case 'set-download-path': {
435
- if (!request.task || !request.downloadPath) {
436
- return { ok: false, error: 'Task and download path required' };
435
+ if (!request.task) {
436
+ return { ok: false, error: 'Task required' };
437
437
  }
438
- await this.service.setDownloadPath(request.task, request.downloadPath, request.tabId);
439
- return { ok: true };
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
- setDownloadPath(taskId: string, downloadPath: string, tabHint?: string): Promise<void>;
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 = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
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 = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
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(getBrowserRuntimeDir(), 'sessions', task.name, 'recordings');
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: downloadPath, completed: false });
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
+ }