@phnx-labs/agents-cli 1.17.1 → 1.17.3

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.17.3
4
+
5
+ **Browser**
6
+
7
+ - `agents browser profiles create` gains `--electron`, `--binary`, and `--target-filter` for driving Electron desktop apps (Canva, Slack, etc.) that expose multiple CDP page targets. The picker matches by `url:<substring>` or `title:<substring>` (case-insensitive) and falls back to a skip-invisible heuristic when no filter is set; misses against an explicit filter throw with the full candidate list. `BrowserService.evaluate` now uses `awaitPromise: true` and surfaces `exceptionDetails` so async script errors propagate as thrown errors. ([#14](https://github.com/phnx-labs/agents-cli/pull/14))
8
+
9
+ **Secrets**
10
+
11
+ - `agents secrets list` rework — drop the misleading `SENSITIVE` column and add `SYNC` (iCloud yes/no) plus `CREATED` / `UPDATED` / `USED` relative-age columns. Timestamps live inside the keychain bundle JSON, are stamped on write (created sticky, updated always advances), and on resolve via a 60s throttle. Set `AGENTS_NO_USAGE_TRACK=1` to disable the usage stamp. `agents secrets view` shows the matching absolute ISO + relative age fields. ([#18](https://github.com/phnx-labs/agents-cli/pull/18))
12
+
13
+ ## 1.17.2
14
+
15
+ **Fixes**
16
+
17
+ - Auto-update prompt no longer hangs in non-interactive environments (CI, k8s pods, cloud sandbox factories). The TTY check now requires both stdin and stdout to be terminals before prompting, and `AGENTS_CLI_DISABLE_AUTO_UPDATE=1` forces the check off entirely for headless deploys. ([#15](https://github.com/phnx-labs/agents-cli/issues/15))
18
+
3
19
  ## 1.17.1
4
20
 
5
21
  **Agent management**
package/README.md CHANGED
@@ -588,6 +588,8 @@ The installer tries Bun first (faster), falls back to npm. Node 18+ required at
588
588
 
589
589
  Yes -- `agents run` is non-interactive by default. `--yes` auto-accepts prompts, `--json` for structured output. Pass explicit names and IDs instead of relying on interactive pickers.
590
590
 
591
+ The auto-update prompt is suppressed automatically when stdin or stdout isn't a TTY. For headless environments where TTY detection misfires (k8s pods that allocate a PTY for stdout, cloud sandbox factories), set `AGENTS_CLI_DISABLE_AUTO_UPDATE=1` to skip the update check entirely -- no prompt, no network call.
592
+
591
593
  ### What happens to my config when I switch versions?
592
594
 
593
595
  Each version has its own isolated config directory. Switching just repoints a symlink — your per-version config stays untouched. On first migration (if you had a real `~/.claude/` directory before using agents-cli), that gets backed up once to `~/.agents-system/backups/`.
@@ -3,6 +3,7 @@ import * as path from 'path';
3
3
  import { listProfiles, getProfile, createProfile, deleteProfile, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, } from '../lib/browser/profiles.js';
4
4
  import { findBrowserPath, getPortOccupant } from '../lib/browser/chrome.js';
5
5
  import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
6
+ import { parseTargetFilter } from '../lib/browser/service.js';
6
7
  import { sendIPCRequest } from '../lib/browser/ipc.js';
7
8
  import { browserTaskPicker } from './browser-picker.js';
8
9
  import { isInteractiveTerminal } from './utils.js';
@@ -51,7 +52,7 @@ function registerProfilesCommands(browser) {
51
52
  }
52
53
  }
53
54
  });
54
- const VALID_BROWSERS = ['chrome', 'comet', 'chromium', 'brave', 'edge'];
55
+ const VALID_BROWSERS = ['chrome', 'comet', 'chromium', 'brave', 'edge', 'custom'];
55
56
  profiles
56
57
  .command('create <name>')
57
58
  .description('Create a new browser profile')
@@ -62,6 +63,9 @@ function registerProfilesCommands(browser) {
62
63
  .option('--headless', 'Run in headless mode')
63
64
  .option('--window <WxH>', 'Window size, e.g. 1512x982')
64
65
  .option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
66
+ .option('--binary <path>', 'Absolute path to the browser/app binary (required with --browser custom)')
67
+ .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')
68
+ .option('--target-filter <expr>', 'Pick the visible CDP page target when the app exposes more than one. Format: url:<substring> or title:<substring>')
65
69
  .action(async (name, opts) => {
66
70
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
67
71
  console.error('Profile name must be lowercase alphanumeric with hyphens');
@@ -71,6 +75,25 @@ function registerProfilesCommands(browser) {
71
75
  console.error(`Invalid browser type. Must be one of: ${VALID_BROWSERS.join(', ')}`);
72
76
  process.exit(1);
73
77
  }
78
+ if (opts.browser === 'custom' && !opts.binary) {
79
+ console.error('--browser custom requires --binary <path>');
80
+ process.exit(1);
81
+ }
82
+ if (opts.targetFilter) {
83
+ // Route through the same parser the runtime uses so the CLI gate matches
84
+ // the runtime contract — `url:` (empty value) and `url: foo` (leading
85
+ // whitespace) both pass a naive `kind` check but produce a silent
86
+ // heuristic fallback at runtime.
87
+ const parsed = parseTargetFilter(String(opts.targetFilter));
88
+ if (!parsed) {
89
+ console.error('--target-filter must be url:<substring> or title:<substring> (non-empty value, no leading whitespace)');
90
+ process.exit(1);
91
+ }
92
+ if (!opts.electron) {
93
+ console.error('--target-filter requires --electron (the filter is only consulted on Electron profiles)');
94
+ process.exit(1);
95
+ }
96
+ }
74
97
  // Auto-assign a free port if no endpoint was provided
75
98
  let endpoints = opts.endpoint;
76
99
  if (endpoints.length === 0) {
@@ -104,6 +127,9 @@ function registerProfilesCommands(browser) {
104
127
  name,
105
128
  description: opts.description,
106
129
  browser: opts.browser,
130
+ binary: opts.binary,
131
+ electron: opts.electron || undefined,
132
+ targetFilter: opts.targetFilter,
107
133
  endpoints,
108
134
  secrets: opts.secrets,
109
135
  chrome: opts.headless ? { headless: true } : undefined,
@@ -133,6 +159,12 @@ function registerProfilesCommands(browser) {
133
159
  }
134
160
  console.log(`Name: ${profile.name}`);
135
161
  console.log(`Browser: ${profile.browser}`);
162
+ if (profile.binary)
163
+ console.log(`Binary: ${profile.binary}`);
164
+ if (profile.electron)
165
+ console.log(`Electron: true`);
166
+ if (profile.targetFilter)
167
+ console.log(`Target filter: ${profile.targetFilter}`);
136
168
  if (profile.description)
137
169
  console.log(`Description: ${profile.description}`);
138
170
  console.log(`Endpoints:`);
@@ -128,16 +128,78 @@ function readStdinSync() {
128
128
  }
129
129
  return Buffer.concat(chunks).toString('utf-8').trim();
130
130
  }
131
+ /** Strip ANSI escape sequences so padding can be computed on visible width. */
132
+ function visibleWidth(s) {
133
+ // eslint-disable-next-line no-control-regex
134
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
135
+ }
136
+ /** padEnd that respects ANSI color codes (chalk-wrapped strings have invisible bytes). */
137
+ function padVisible(s, n) {
138
+ const w = visibleWidth(s);
139
+ if (w >= n)
140
+ return s;
141
+ return s + ' '.repeat(n - w);
142
+ }
143
+ /** Render an ISO-8601 timestamp as a compact relative age: "now", "5m", "1h", "3d", "2w", "4mo", "1y". */
144
+ function relativeAge(iso) {
145
+ const t = Date.parse(iso);
146
+ if (!Number.isFinite(t))
147
+ return '-';
148
+ const deltaMs = Date.now() - t;
149
+ if (deltaMs < 0)
150
+ return 'now';
151
+ const sec = Math.floor(deltaMs / 1000);
152
+ if (sec < 60)
153
+ return 'now';
154
+ const min = Math.floor(sec / 60);
155
+ if (min < 60)
156
+ return `${min}m`;
157
+ const hr = Math.floor(min / 60);
158
+ if (hr < 24)
159
+ return `${hr}h`;
160
+ const day = Math.floor(hr / 24);
161
+ if (day < 7)
162
+ return `${day}d`;
163
+ if (day < 30)
164
+ return `${Math.floor(day / 7)}w`;
165
+ const mo = Math.floor(day / 30);
166
+ if (mo < 12)
167
+ return `${mo}mo`;
168
+ return `${Math.floor(day / 365)}y`;
169
+ }
170
+ /** Long-form relative age for the `view` command. "now" stays as "now"; otherwise appends " ago". */
171
+ function humanAge(iso) {
172
+ const age = relativeAge(iso);
173
+ if (age === 'now' || age === '-')
174
+ return age;
175
+ return `${age} ago`;
176
+ }
131
177
  /** Format a single bundle as a table row for the `secrets list` output. */
132
178
  function renderBundleRow(b) {
133
179
  const entries = describeBundle(b);
134
180
  const keys = entries.length;
135
- const sensitive = entries.filter((e) => e.kind === 'keychain').length;
136
- const expiring = countExpiringSoon(b.meta);
137
- const expiringCol = expiring > 0
138
- ? chalk.yellow(String(expiring).padEnd(10))
139
- : ''.padEnd(10);
140
- return `${chalk.cyan(b.name.padEnd(20))} ${String(keys).padEnd(6)} ${chalk.yellow(String(sensitive).padEnd(10))} ${expiringCol} ${chalk.gray(b.description || '')}`;
181
+ const sync = b.icloud_sync ? chalk.cyan('icloud') : '';
182
+ const expiringCount = countExpiringSoon(b.meta);
183
+ const expiring = expiringCount > 0 ? chalk.yellow(String(expiringCount)) : chalk.gray('-');
184
+ // Timestamp distinction:
185
+ // "?" -> legacy bundle, never written under the timestamping code.
186
+ // "never" -> bundle has been written but the action never happened
187
+ // (currently only used for USED — CREATED/UPDATED are always
188
+ // set together by writeBundle).
189
+ // <age> -> real data.
190
+ const created = b.created_at ? relativeAge(b.created_at) : chalk.gray('?');
191
+ const updated = b.updated_at ? relativeAge(b.updated_at) : chalk.gray('?');
192
+ const used = b.last_used
193
+ ? relativeAge(b.last_used)
194
+ : (b.created_at ? chalk.gray('never') : chalk.gray('?'));
195
+ const head = `${chalk.cyan(b.name.padEnd(20))} ` +
196
+ `${String(keys).padEnd(5)} ` +
197
+ `${padVisible(sync, 6)} ` +
198
+ `${padVisible(expiring, 9)} ` +
199
+ `${padVisible(created, 9)} ` +
200
+ `${padVisible(updated, 9)} ` +
201
+ `${padVisible(used, 7)}`;
202
+ return b.description ? `${head} ${chalk.gray(b.description)}` : head.trimEnd();
141
203
  }
142
204
  /** Colorize a variable source kind (literal, keychain, env, file, exec). */
143
205
  function kindLabel(kind) {
@@ -340,7 +402,7 @@ Examples:
340
402
  console.log(chalk.gray('Try: agents secrets create <name>'));
341
403
  return;
342
404
  }
343
- console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(6)} ${'SENSITIVE'.padEnd(10)} ${'EXPIRING'.padEnd(10)} DESCRIPTION`));
405
+ console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(5)} ${'SYNC'.padEnd(6)} ${'EXPIRING'.padEnd(9)} ${'CREATED'.padEnd(9)} ${'UPDATED'.padEnd(9)} ${'USED'.padEnd(7)} DESCRIPTION`));
344
406
  for (const b of bundles) {
345
407
  console.log(renderBundleRow(b));
346
408
  }
@@ -363,6 +425,12 @@ Examples:
363
425
  console.log(chalk.yellow('allow_exec: true'));
364
426
  if (bundle.icloud_sync)
365
427
  console.log(chalk.cyan('icloud_sync: true'));
428
+ if (bundle.created_at)
429
+ console.log(chalk.gray(`created_at: ${bundle.created_at} (${humanAge(bundle.created_at)})`));
430
+ if (bundle.updated_at)
431
+ console.log(chalk.gray(`updated_at: ${bundle.updated_at} (${humanAge(bundle.updated_at)})`));
432
+ if (bundle.last_used)
433
+ console.log(chalk.gray(`last_used: ${bundle.last_used} (${humanAge(bundle.last_used)})`));
366
434
  console.log();
367
435
  if (entries.length === 0) {
368
436
  console.log(chalk.gray('(no keys)'));
package/dist/index.js CHANGED
@@ -60,7 +60,7 @@ import { registerUsageCommand } from './commands/usage.js';
60
60
  import { registerAliasCommand } from './commands/alias.js';
61
61
  import { registerBetaCommands } from './commands/beta.js';
62
62
  import { applyGlobalHelpConventions } from './lib/help.js';
63
- import { isPromptCancelled } from './commands/utils.js';
63
+ import { isInteractiveTerminal, isPromptCancelled } from './commands/utils.js';
64
64
  import { AGENTS } from './lib/agents.js';
65
65
  import { getGlobalDefault, listInstalledVersions } from './lib/versions.js';
66
66
  import { addShimsToPath, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, hasAliasShadowingShim, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } from './lib/shims.js';
@@ -240,7 +240,7 @@ function saveUpdateCheck(latestVersion) {
240
240
  }
241
241
  /** Present an interactive upgrade prompt (TTY) or a one-line hint (non-TTY). */
242
242
  async function promptUpgrade(latestVersion) {
243
- if (!process.stdout.isTTY) {
243
+ if (!isInteractiveTerminal()) {
244
244
  console.error(chalk.yellow(`Update available: ${VERSION} -> ${latestVersion}. Run: npm install -g @phnx-labs/agents-cli@latest`));
245
245
  return;
246
246
  }
@@ -309,6 +309,8 @@ function refreshUpdateCacheInBackground() {
309
309
  }
310
310
  /** Check for available updates using the local cache. Triggers a background refresh if stale. */
311
311
  async function checkForUpdates() {
312
+ if (process.env.AGENTS_CLI_DISABLE_AUTO_UPDATE)
313
+ return;
312
314
  const cache = readUpdateCache();
313
315
  // Kick off network refresh in background if stale. Does not block.
314
316
  if (shouldFetchLatest(cache)) {
@@ -15,6 +15,7 @@ function configToProfile(name, config) {
15
15
  browser: config.browser,
16
16
  binary: config.binary,
17
17
  electron: config.electron,
18
+ targetFilter: config.targetFilter,
18
19
  endpoints: config.endpoints,
19
20
  chrome: config.chrome,
20
21
  secrets: config.secrets,
@@ -32,6 +33,8 @@ function profileToConfig(profile) {
32
33
  config.binary = profile.binary;
33
34
  if (profile.electron)
34
35
  config.electron = profile.electron;
36
+ if (profile.targetFilter)
37
+ config.targetFilter = profile.targetFilter;
35
38
  if (profile.chrome)
36
39
  config.chrome = profile.chrome;
37
40
  if (profile.secrets)
@@ -1,5 +1,38 @@
1
1
  import { type TabInfo, type ProfileStatus, type HistoricalTask } from './types.js';
2
2
  import { type RefOpts, type RefNode } from './refs.js';
3
+ import type { TargetFilter } from './types.js';
4
+ /**
5
+ * Parse a `targetFilter` string into its kind + value, or return `null`
6
+ * when the input is missing or malformed. Filter syntax:
7
+ * - `url:<substring>` — picks the first page target whose URL contains the substring
8
+ * - `title:<substring>` — picks the first page target whose title contains the substring
9
+ *
10
+ * The match is case-insensitive on both sides because Electron apps
11
+ * frequently lowercase or title-case their target metadata in unpredictable ways.
12
+ */
13
+ export declare function parseTargetFilter(filter: string | undefined): TargetFilter | null;
14
+ /**
15
+ * Choose the CDP page target that represents the visible UI.
16
+ *
17
+ * Order:
18
+ * 1. If `filter` is set and parseable, narrow to page targets matching it
19
+ * (case-insensitive substring). Among matches, prefer one that is not in
20
+ * `INVISIBLE_URL_PATTERNS` — this is the tiebreaker that makes a coarse
21
+ * filter like `url:https://www.canva.com/` skip the background service
22
+ * (`https://www.canva.com/_desktop-background-service` *also* matches the
23
+ * substring). If every match is invisible, return the first match so the
24
+ * caller still gets something rather than silently falling through.
25
+ * An explicit filter that finds *no* match returns `undefined` — callers
26
+ * should surface this as an error rather than create an orphan window.
27
+ * 2. If `filter` is unset (or unparseable), apply the skip-invisible heuristic
28
+ * across all page targets.
29
+ * 3. As a last resort, return the first page target.
30
+ */
31
+ export declare function pickWindowTarget<T extends {
32
+ type: string;
33
+ url?: string;
34
+ title?: string;
35
+ }>(targets: T[], filter: string | undefined): T | undefined;
3
36
  export declare class BrowserService {
4
37
  private connections;
5
38
  private forkingProfiles;
@@ -9,6 +9,87 @@ import { generateTaskId, generateShortId, generateFunName, } from './types.js';
9
9
  import { getRefs, resolveRefToCoords } from './refs.js';
10
10
  import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
11
11
  import { emit } from '../events.js';
12
+ /**
13
+ * Parse a `targetFilter` string into its kind + value, or return `null`
14
+ * when the input is missing or malformed. Filter syntax:
15
+ * - `url:<substring>` — picks the first page target whose URL contains the substring
16
+ * - `title:<substring>` — picks the first page target whose title contains the substring
17
+ *
18
+ * The match is case-insensitive on both sides because Electron apps
19
+ * frequently lowercase or title-case their target metadata in unpredictable ways.
20
+ */
21
+ export function parseTargetFilter(filter) {
22
+ if (!filter)
23
+ return null;
24
+ const idx = filter.indexOf(':');
25
+ if (idx <= 0)
26
+ return null;
27
+ const kind = filter.slice(0, idx).trim().toLowerCase();
28
+ // Strip whitespace around the value so `url: https://x` (with a copy-pasted
29
+ // space after the colon) doesn't silently fail to match — `.includes(' x')`
30
+ // never hits a URL because URLs don't contain spaces.
31
+ const value = filter.slice(idx + 1).trim();
32
+ if (kind !== 'url' && kind !== 'title')
33
+ return null;
34
+ if (!value)
35
+ return null;
36
+ return { kind, value };
37
+ }
38
+ /**
39
+ * URLs that the skip-invisible heuristic excludes when no explicit filter
40
+ * matches. These are page targets Electron apps ship for housekeeping;
41
+ * picking one means screenshots come back blank.
42
+ */
43
+ const INVISIBLE_URL_PATTERNS = [
44
+ /^about:blank$/i,
45
+ /^file:\/\//i,
46
+ /\/_desktop-background-service(\?|$|\/)/i,
47
+ /\/_internal(\?|$|\/)/i,
48
+ /\/_background(\?|$|\/)/i,
49
+ ];
50
+ function isLikelyInvisible(url) {
51
+ if (!url)
52
+ return true;
53
+ return INVISIBLE_URL_PATTERNS.some((re) => re.test(url));
54
+ }
55
+ /**
56
+ * Choose the CDP page target that represents the visible UI.
57
+ *
58
+ * Order:
59
+ * 1. If `filter` is set and parseable, narrow to page targets matching it
60
+ * (case-insensitive substring). Among matches, prefer one that is not in
61
+ * `INVISIBLE_URL_PATTERNS` — this is the tiebreaker that makes a coarse
62
+ * filter like `url:https://www.canva.com/` skip the background service
63
+ * (`https://www.canva.com/_desktop-background-service` *also* matches the
64
+ * substring). If every match is invisible, return the first match so the
65
+ * caller still gets something rather than silently falling through.
66
+ * An explicit filter that finds *no* match returns `undefined` — callers
67
+ * should surface this as an error rather than create an orphan window.
68
+ * 2. If `filter` is unset (or unparseable), apply the skip-invisible heuristic
69
+ * across all page targets.
70
+ * 3. As a last resort, return the first page target.
71
+ */
72
+ export function pickWindowTarget(targets, filter) {
73
+ const pages = targets.filter((t) => t.type === 'page');
74
+ if (pages.length === 0)
75
+ return undefined;
76
+ const parsed = parseTargetFilter(filter);
77
+ if (parsed) {
78
+ const needle = parsed.value.toLowerCase();
79
+ const matches = pages.filter((t) => {
80
+ const hay = (parsed.kind === 'url' ? t.url : t.title) ?? '';
81
+ return hay.toLowerCase().includes(needle);
82
+ });
83
+ if (matches.length === 0)
84
+ return undefined;
85
+ const visible = matches.find((t) => !isLikelyInvisible(t.url));
86
+ return visible ?? matches[0];
87
+ }
88
+ const visible = pages.find((t) => !isLikelyInvisible(t.url));
89
+ if (visible)
90
+ return visible;
91
+ return pages[0];
92
+ }
12
93
  export class BrowserService {
13
94
  connections = new Map();
14
95
  forkingProfiles = new Set();
@@ -351,7 +432,25 @@ export class BrowserService {
351
432
  throw new Error(`Tab ${shortId} not found`);
352
433
  }
353
434
  const sessionId = await this.getSessionId(conn, target.targetId);
354
- const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId));
435
+ // `awaitPromise: true` lets callers write `evaluate '(async () => {...})()'`
436
+ // and get the resolved value back instead of a stringified Promise. This
437
+ // is essential for any flow that needs sub-step waits inside the page
438
+ // (e.g. driving a multi-step modal where each step needs React to settle
439
+ // before the next call). Without it, the shell-side workaround is to
440
+ // chain N separate `evaluate` calls with `sleep` between them, which
441
+ // races against the page's own state machine.
442
+ //
443
+ // `exceptionDetails` is surfaced as a thrown error so a rejected promise
444
+ // or a thrown error inside the expression doesn't silently return `undefined`.
445
+ const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId));
446
+ if (result.exceptionDetails) {
447
+ const ex = result.exceptionDetails;
448
+ const msg = ex.exception?.description ??
449
+ (typeof ex.exception?.value === 'string' ? ex.exception.value : undefined) ??
450
+ ex.text ??
451
+ 'evaluate failed';
452
+ throw new Error(msg);
453
+ }
355
454
  return result.result.value;
356
455
  }
357
456
  async screenshot(taskId, tabHint, outputPath) {
@@ -839,6 +938,7 @@ export class BrowserService {
839
938
  port,
840
939
  pid,
841
940
  electron: true,
941
+ targetFilter: profile.targetFilter,
842
942
  forkedFrom: profile.name,
843
943
  tasks: new Map(),
844
944
  sessionCache: new Map(),
@@ -860,6 +960,7 @@ export class BrowserService {
860
960
  port: existingInfo.port,
861
961
  pid: existingInfo.pid,
862
962
  electron: profile.electron,
963
+ targetFilter: profile.targetFilter,
863
964
  tasks,
864
965
  sessionCache: new Map(),
865
966
  };
@@ -889,6 +990,7 @@ export class BrowserService {
889
990
  port: conn.port,
890
991
  pid: conn.pid,
891
992
  electron: profile.electron,
993
+ targetFilter: profile.targetFilter,
892
994
  tasks: conn.pid === 0 ? this.loadTaskState(profile.name) : new Map(),
893
995
  sessionCache: new Map(),
894
996
  };
@@ -901,6 +1003,7 @@ export class BrowserService {
901
1003
  port: conn.port,
902
1004
  pid: conn.pid,
903
1005
  electron: profile.electron,
1006
+ targetFilter: profile.targetFilter,
904
1007
  tasks: new Map(),
905
1008
  sessionCache: new Map(),
906
1009
  };
@@ -914,6 +1017,7 @@ export class BrowserService {
914
1017
  port: 0,
915
1018
  pid: 0,
916
1019
  electron: profile.electron,
1020
+ targetFilter: profile.targetFilter,
917
1021
  tasks: this.loadTaskState(profile.name),
918
1022
  sessionCache: new Map(),
919
1023
  };
@@ -930,6 +1034,7 @@ export class BrowserService {
930
1034
  port,
931
1035
  pid: 0,
932
1036
  electron: profile.electron,
1037
+ targetFilter: profile.targetFilter,
933
1038
  tasks: this.loadTaskState(profile.name),
934
1039
  sessionCache: new Map(),
935
1040
  };
@@ -951,11 +1056,24 @@ export class BrowserService {
951
1056
  }
952
1057
  // Check if browser already has a page target we can use
953
1058
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
954
- const existing = targetInfos.find((t) => t.type === 'page');
1059
+ const existing = pickWindowTarget(targetInfos, conn.targetFilter);
955
1060
  if (existing) {
956
1061
  conn.windowId = existing.targetId;
957
1062
  return existing.targetId;
958
1063
  }
1064
+ // If we have an explicit filter, `pickWindowTarget` returns undefined when nothing
1065
+ // matches. That almost always means the profile is misconfigured (typo in the
1066
+ // filter, target hasn't loaded yet, app version moved the URL). Falling through
1067
+ // to `Target.createTarget` would silently create an orphan tab the user can't see.
1068
+ // Surface the failure instead, with the candidate list so the fix is obvious.
1069
+ if (parseTargetFilter(conn.targetFilter)) {
1070
+ const candidates = targetInfos
1071
+ .filter((t) => t.type === 'page')
1072
+ .map((t) => ` - url=${t.url ?? ''} title=${t.title ?? ''}`)
1073
+ .join('\n');
1074
+ throw new Error(`Target filter ${JSON.stringify(conn.targetFilter)} matched no page target.\n` +
1075
+ `Available page targets:\n${candidates || ' (none)'}`);
1076
+ }
959
1077
  // First ever use - create window
960
1078
  const result = (await conn.cdp.send('Target.createTarget', {
961
1079
  url: 'about:blank',
@@ -5,6 +5,11 @@ export interface BrowserProfile {
5
5
  browser: BrowserType;
6
6
  binary?: string;
7
7
  electron?: boolean;
8
+ /**
9
+ * `url:<substring>` or `title:<substring>`. Picks which CDP page target
10
+ * represents the visible UI for Electron apps with multiple WebContents.
11
+ */
12
+ targetFilter?: string;
8
13
  endpoints: string[];
9
14
  chrome?: ChromeOptions;
10
15
  secrets?: string;
@@ -15,6 +20,11 @@ export interface BrowserProfile {
15
20
  y?: number;
16
21
  };
17
22
  }
23
+ /** Parsed form of `BrowserProfile.targetFilter`. */
24
+ export interface TargetFilter {
25
+ kind: 'url' | 'title';
26
+ value: string;
27
+ }
18
28
  export interface ChromeOptions {
19
29
  headless?: boolean;
20
30
  args?: string[];
@@ -30,6 +30,12 @@ export interface SecretsBundle {
30
30
  allow_exec?: boolean;
31
31
  /** When true, keychain-backed values and bundle metadata sync via iCloud Keychain. */
32
32
  icloud_sync?: boolean;
33
+ /** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
34
+ created_at?: string;
35
+ /** ISO 8601 UTC timestamp. Refreshed on every writeBundle(). */
36
+ updated_at?: string;
37
+ /** ISO 8601 UTC timestamp. Stamped by resolveBundleEnv (throttled). */
38
+ last_used?: string;
33
39
  vars: Record<string, BundleValue>;
34
40
  /** Optional per-var metadata, keyed by var name (parallel to `vars`). */
35
41
  meta?: Record<string, VarMeta>;
@@ -29,6 +29,8 @@ export const SECRET_TYPES = [
29
29
  'webhook',
30
30
  'note',
31
31
  ];
32
+ /** Minimum gap between last_used updates so the keychain isn't written on every secrets injection. */
33
+ const LAST_USED_THROTTLE_MS = 60_000;
32
34
  const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
33
35
  const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
34
36
  const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
@@ -109,6 +111,12 @@ export function readBundle(name) {
109
111
  icloud_sync: Boolean(parsed.icloud_sync),
110
112
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
111
113
  };
114
+ if (typeof parsed.created_at === 'string')
115
+ bundle.created_at = parsed.created_at;
116
+ if (typeof parsed.updated_at === 'string')
117
+ bundle.updated_at = parsed.updated_at;
118
+ if (typeof parsed.last_used === 'string')
119
+ bundle.last_used = parsed.last_used;
112
120
  if (parsed.meta && typeof parsed.meta === 'object') {
113
121
  bundle.meta = parsed.meta;
114
122
  }
@@ -140,10 +148,20 @@ export function writeBundle(bundle) {
140
148
  }
141
149
  }
142
150
  }
151
+ // Stamp timestamps on the bundle so callers see what got persisted. created_at
152
+ // is sticky — once set we never overwrite it, including on legacy bundles
153
+ // that already carry one. updated_at always advances.
154
+ const now = new Date().toISOString();
155
+ if (!bundle.created_at)
156
+ bundle.created_at = now;
157
+ bundle.updated_at = now;
143
158
  const payload = {
144
159
  description: bundle.description,
145
160
  allow_exec: bundle.allow_exec ? true : undefined,
146
161
  icloud_sync: bundle.icloud_sync ? true : undefined,
162
+ created_at: bundle.created_at,
163
+ updated_at: bundle.updated_at,
164
+ last_used: bundle.last_used,
147
165
  vars: bundle.vars,
148
166
  meta,
149
167
  };
@@ -194,11 +212,33 @@ export function describeBundle(bundle) {
194
212
  }
195
213
  return out;
196
214
  }
215
+ // Bump `bundle.last_used` and persist the bundle, but no more than once per
216
+ // throttle window so we don't pay a keychain write on every agent run. Failures
217
+ // are swallowed — usage tracking is never allowed to break secret resolution.
218
+ // Set AGENTS_NO_USAGE_TRACK=1 to disable the stamp entirely (used by tests).
219
+ function stampLastUsed(bundle) {
220
+ if (process.env.AGENTS_NO_USAGE_TRACK)
221
+ return;
222
+ const nowMs = Date.now();
223
+ if (bundle.last_used) {
224
+ const prev = Date.parse(bundle.last_used);
225
+ if (Number.isFinite(prev) && nowMs - prev < LAST_USED_THROTTLE_MS)
226
+ return;
227
+ }
228
+ try {
229
+ bundle.last_used = new Date(nowMs).toISOString();
230
+ writeBundle(bundle);
231
+ }
232
+ catch {
233
+ // Swallow — telemetry must never block secret resolution.
234
+ }
235
+ }
197
236
  // Walk the bundle and produce a flat env map. Keychain refs are translated via
198
237
  // the bundle-scoped naming scheme so two bundles with the same short ID never
199
238
  // collide. Throws on the first missing secret so `agents run` fails loudly
200
239
  // rather than silently injecting empty strings.
201
240
  export function resolveBundleEnv(bundle) {
241
+ stampLastUsed(bundle);
202
242
  const env = {};
203
243
  for (const [key, raw] of Object.entries(bundle.vars)) {
204
244
  const parsed = parseBundleValue(raw);
@@ -426,6 +426,16 @@ export interface BrowserProfileConfig {
426
426
  browser: 'chrome' | 'comet' | 'chromium' | 'brave' | 'edge' | 'custom';
427
427
  binary?: string;
428
428
  electron?: boolean;
429
+ /**
430
+ * Selects which CDP page target represents the visible UI when the
431
+ * browser/app exposes more than one. Format: `url:<substring>` or
432
+ * `title:<substring>`. Recommended for Electron apps that ship hidden
433
+ * helper WebContents (background services, OAuth windows, file://
434
+ * shells); without an explicit filter the connector falls back to a
435
+ * skip-invisible heuristic before picking the first page target.
436
+ * Only consulted when `electron` is true.
437
+ */
438
+ targetFilter?: string;
429
439
  endpoints: string[];
430
440
  chrome?: {
431
441
  headless?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.17.1",
3
+ "version": "1.17.3",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",