@phnx-labs/agents-cli 1.17.2 → 1.17.4

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.17.4
4
+
5
+ **Browser**
6
+
7
+ - `agents browser type` now detects rich-text editor frameworks (Lexical, ProseMirror, Slate, Draft.js, Quill, CKEditor5, Trix) by walking up to 5 ancestor levels from each textbox and tagging refs with `[editor=<framework>]`. Editor-tagged refs route through the WHATWG `beforeinput` dispatch (`InputEvent('beforeinput', { inputType: 'insertText', ... })`) for Lexical/ProseMirror/Slate/Quill/CKEditor5/Draft and `el.editor.insertString()` for Trix. `agents browser refs --json` surfaces the new `editor` field, and `type --clear` prepends a select-all + `deleteContentBackward` dispatch before inserting.
8
+ - Plain-input reliability also improved: `typeText` now issues a single CDP `Input.insertText` instead of per-character `dispatchKeyEvent`, so framework-controlled inputs (React, Vue, Solid, MUI/Chakra/Mantine `TextField`, masked-number fields, Canva-style pickers) actually receive `beforeinput`/`input`/`textInput` events. `focusNode` falls back to the first focusable descendant when `DOM.focus` throws "Element is not focusable" — fixes wrapper-ref UIs like Slack composer, Linear comments, Notion blocks, and every MUI/Chakra/Mantine `TextField`. ([#12](https://github.com/phnx-labs/agents-cli/pull/12))
9
+
10
+ ## 1.17.3
11
+
12
+ **Browser**
13
+
14
+ - `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))
15
+
16
+ **Secrets**
17
+
18
+ - `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))
19
+
3
20
  ## 1.17.2
4
21
 
5
22
  **Fixes**
@@ -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:`);
@@ -722,6 +754,7 @@ function registerTaskCommands(browser) {
722
754
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
723
755
  .option('--all', 'Include non-interactive elements')
724
756
  .option('-l, --limit <n>', 'Max elements (default 500)', '500')
757
+ .option('--json', 'Output machine-readable JSON')
725
758
  .action(async (task, opts) => {
726
759
  const response = await sendIPCRequest({
727
760
  action: 'refs',
@@ -731,9 +764,18 @@ function registerTaskCommands(browser) {
731
764
  limit: parseInt(opts.limit, 10),
732
765
  });
733
766
  if (!response.ok) {
734
- console.error(response.error);
767
+ if (opts.json) {
768
+ console.log(JSON.stringify({ ok: false, error: response.error }));
769
+ }
770
+ else {
771
+ console.error(response.error);
772
+ }
735
773
  process.exit(1);
736
774
  }
775
+ if (opts.json) {
776
+ console.log(JSON.stringify(response.nodes ?? [], null, 2));
777
+ return;
778
+ }
737
779
  console.log(response.refs);
738
780
  });
739
781
  browser
@@ -757,6 +799,7 @@ function registerTaskCommands(browser) {
757
799
  .command('type <task> <ref> <text>')
758
800
  .description('Type text into an element by ref')
759
801
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
802
+ .option('--clear', 'Clear editor content before typing')
760
803
  .action(async (task, ref, text, opts) => {
761
804
  const response = await sendIPCRequest({
762
805
  action: 'type',
@@ -764,6 +807,7 @@ function registerTaskCommands(browser) {
764
807
  tabId: opts.tab,
765
808
  ref: parseInt(ref, 10),
766
809
  text,
810
+ clear: opts.clear,
767
811
  });
768
812
  if (!response.ok) {
769
813
  console.error(response.error);
@@ -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)'));
@@ -0,0 +1,3 @@
1
+ import type { CDPClient } from './cdp.js';
2
+ import type { RefNode } from './refs.js';
3
+ export declare function typeEditorText(cdp: CDPClient, sessionId: string, node: RefNode, text: string, clear?: boolean): Promise<void>;
@@ -0,0 +1,50 @@
1
+ const BEFOREINPUT_INSERT_FN = `(function(text) {
2
+ this.focus();
3
+ var sel = window.getSelection();
4
+ var range = document.createRange();
5
+ range.selectNodeContents(this);
6
+ range.collapse(false);
7
+ sel.removeAllRanges();
8
+ sel.addRange(range);
9
+ this.dispatchEvent(new InputEvent('beforeinput', {
10
+ inputType: 'insertText',
11
+ data: text,
12
+ bubbles: true,
13
+ cancelable: true,
14
+ composed: true,
15
+ }));
16
+ })`;
17
+ const BEFOREINPUT_CLEAR_FN = `(function() {
18
+ this.focus();
19
+ var sel = window.getSelection();
20
+ var range = document.createRange();
21
+ range.selectNodeContents(this);
22
+ sel.removeAllRanges();
23
+ sel.addRange(range);
24
+ this.dispatchEvent(new InputEvent('beforeinput', {
25
+ inputType: 'deleteContentBackward',
26
+ bubbles: true,
27
+ cancelable: true,
28
+ composed: true,
29
+ }));
30
+ })`;
31
+ const TRIX_INSERT_FN = `(function(text) { this.editor.insertString(text); })`;
32
+ export async function typeEditorText(cdp, sessionId, node, text, clear = false) {
33
+ const { object } = await cdp.send('DOM.resolveNode', { backendNodeId: node.backendNodeId }, sessionId);
34
+ if (!object.objectId)
35
+ throw new Error(`Could not resolve DOM node for ref ${node.ref}`);
36
+ const objectId = object.objectId;
37
+ try {
38
+ if (node.editor === 'trix') {
39
+ await cdp.send('Runtime.callFunctionOn', { objectId, functionDeclaration: TRIX_INSERT_FN, arguments: [{ value: text }], returnByValue: true }, sessionId);
40
+ return;
41
+ }
42
+ if (clear) {
43
+ await cdp.send('Runtime.callFunctionOn', { objectId, functionDeclaration: BEFOREINPUT_CLEAR_FN, arguments: [], returnByValue: true }, sessionId);
44
+ }
45
+ await cdp.send('Runtime.callFunctionOn', { objectId, functionDeclaration: BEFOREINPUT_INSERT_FN, arguments: [{ value: text }], returnByValue: true }, sessionId);
46
+ }
47
+ finally {
48
+ await cdp.send('Runtime.releaseObject', { objectId }, sessionId);
49
+ }
50
+ }
@@ -8,11 +8,13 @@ export async function hoverAtCoords(cdp, sessionId, x, y) {
8
8
  export async function scrollAtCoords(cdp, sessionId, x, y, deltaX, deltaY) {
9
9
  await cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX, deltaY }, sessionId);
10
10
  }
11
+ // `Input.insertText` is the CDP native text-insertion method. It dispatches a
12
+ // real `beforeinput`/`input`/`textInput` sequence on the focused element, which
13
+ // is what framework-controlled inputs (React, Vue, Solid, contenteditable
14
+ // editors) actually listen for. Per-character `dispatchKeyEvent` only fires
15
+ // `keydown`/`keyup` with no input event, so controlled inputs ignore it.
11
16
  export async function typeText(cdp, sessionId, text) {
12
- for (const char of text) {
13
- await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', text: char }, sessionId);
14
- await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', text: char }, sessionId);
15
- }
17
+ await cdp.send('Input.insertText', { text }, sessionId);
16
18
  }
17
19
  const KEY_CODES = {
18
20
  Enter: { key: 'Enter', code: 'Enter', keyCode: 13 },
@@ -50,6 +52,41 @@ export async function pressKey(cdp, sessionId, keyName) {
50
52
  nativeVirtualKeyCode: keyInfo.keyCode,
51
53
  }, sessionId);
52
54
  }
55
+ const FOCUS_DESCENDANT_FN = `(function() {
56
+ const selector = 'input:not([disabled]):not([type=hidden]),textarea:not([disabled]),select:not([disabled]),[contenteditable=""],[contenteditable=true],[tabindex]:not([tabindex="-1"])';
57
+ const candidates = this.querySelectorAll(selector);
58
+ for (const el of candidates) {
59
+ el.focus();
60
+ if (document.activeElement === el) return true;
61
+ }
62
+ return false;
63
+ })`;
64
+ // `DOM.focus` only works on natively focusable elements. UIs that wrap real
65
+ // inputs in styled containers (Slack composer, Linear comments, Notion blocks,
66
+ // Canva pickers, MUI/Chakra/Mantine TextField) often expose the wrapper as the
67
+ // accessible "ref" — focusing it throws "Element is not focusable". When that
68
+ // happens, walk the subtree for the first focusable descendant.
53
69
  export async function focusNode(cdp, sessionId, backendNodeId) {
54
- await cdp.send('DOM.focus', { backendNodeId }, sessionId);
70
+ try {
71
+ await cdp.send('DOM.focus', { backendNodeId }, sessionId);
72
+ return;
73
+ }
74
+ catch (err) {
75
+ const focused = await focusFirstFocusableDescendant(cdp, sessionId, backendNodeId);
76
+ if (!focused)
77
+ throw err;
78
+ }
79
+ }
80
+ async function focusFirstFocusableDescendant(cdp, sessionId, backendNodeId) {
81
+ const { object } = await cdp.send('DOM.resolveNode', { backendNodeId }, sessionId);
82
+ if (!object.objectId)
83
+ return false;
84
+ const objectId = object.objectId;
85
+ try {
86
+ const { result } = await cdp.send('Runtime.callFunctionOn', { objectId, functionDeclaration: FOCUS_DESCENDANT_FN, returnByValue: true }, sessionId);
87
+ return result.value === true;
88
+ }
89
+ finally {
90
+ await cdp.send('Runtime.releaseObject', { objectId }, sessionId);
91
+ }
55
92
  }
@@ -164,11 +164,17 @@ export class BrowserIPCServer {
164
164
  if (!request.task) {
165
165
  return { ok: false, error: 'Task required' };
166
166
  }
167
- const { refs } = await this.service.refs(request.task, request.tabId, {
167
+ const { refs, nodeMap } = await this.service.refs(request.task, request.tabId, {
168
168
  interactive: request.interactive ?? true,
169
169
  limit: request.limit ?? 500,
170
170
  });
171
- return { ok: true, refs };
171
+ const nodes = Array.from(nodeMap.values()).map(n => {
172
+ const entry = { ref: n.ref, role: n.role, name: n.name, attrs: n.attrs };
173
+ if (n.editor !== undefined)
174
+ entry.editor = n.editor;
175
+ return entry;
176
+ });
177
+ return { ok: true, refs, nodes };
172
178
  }
173
179
  case 'click': {
174
180
  if (!request.task || request.ref === undefined) {
@@ -181,7 +187,7 @@ export class BrowserIPCServer {
181
187
  if (!request.task || request.ref === undefined || !request.text) {
182
188
  return { ok: false, error: 'Task, ref, and text required' };
183
189
  }
184
- await this.service.type(request.task, request.ref, request.text, request.tabId);
190
+ await this.service.type(request.task, request.ref, request.text, request.tabId, request.clear);
185
191
  return { ok: true };
186
192
  }
187
193
  case 'press': {
@@ -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)
@@ -10,6 +10,7 @@ export interface RefNode {
10
10
  name: string;
11
11
  attrs: string[];
12
12
  backendNodeId?: number;
13
+ editor?: string;
13
14
  }
14
15
  export declare function getRefs(cdp: CDPClient, sessionId: string, opts?: RefOpts): Promise<{
15
16
  refs: string;
@@ -1,3 +1,31 @@
1
+ const EDITOR_DETECT_FN = `(function() {
2
+ let el = this;
3
+ for (let i = 0; i < 5; i++) {
4
+ if (!el || el === document.documentElement) break;
5
+ if (el.hasAttribute && el.hasAttribute('data-lexical-editor')) return 'lexical';
6
+ if (el.classList && el.classList.contains('ProseMirror')) return 'prosemirror';
7
+ if (el.hasAttribute && el.hasAttribute('data-slate-editor')) return 'slate';
8
+ if (el.classList && Array.from(el.classList).some(function(c) { return /^DraftEditor-/.test(c); })) return 'draft';
9
+ if (el.classList && el.classList.contains('ql-editor')) return 'quill';
10
+ if (el.classList && el.classList.contains('ck-editor__editable')) return 'ckeditor5';
11
+ if (el.tagName === 'TRIX-EDITOR') return 'trix';
12
+ el = el.parentElement;
13
+ }
14
+ return null;
15
+ })`;
16
+ async function detectEditorForNode(cdp, sessionId, backendNodeId) {
17
+ const { object } = await cdp.send('DOM.resolveNode', { backendNodeId }, sessionId);
18
+ if (!object.objectId)
19
+ return undefined;
20
+ const objectId = object.objectId;
21
+ try {
22
+ const { result } = await cdp.send('Runtime.callFunctionOn', { objectId, functionDeclaration: EDITOR_DETECT_FN, returnByValue: true }, sessionId);
23
+ return result.value ?? undefined;
24
+ }
25
+ finally {
26
+ await cdp.send('Runtime.releaseObject', { objectId }, sessionId);
27
+ }
28
+ }
1
29
  const INTERACTIVE_ROLES = new Set([
2
30
  'button',
3
31
  'link',
@@ -59,12 +87,18 @@ export async function getRefs(cdp, sessionId, opts = {}) {
59
87
  attrs,
60
88
  backendNodeId: node.backendDOMNodeId,
61
89
  };
90
+ if (role === 'textbox' && node.backendDOMNodeId) {
91
+ const editor = await detectEditorForNode(cdp, sessionId, node.backendDOMNodeId);
92
+ if (editor)
93
+ refNode.editor = editor;
94
+ }
62
95
  nodeMap.set(ref, refNode);
63
96
  const attrStr = attrs.length > 0 ? ` [${attrs.join('] [')}]` : '';
97
+ const editorStr = refNode.editor ? ` [editor=${refNode.editor}]` : '';
64
98
  const nameStr = name ? ` "${truncate(name, 50)}"` : '';
65
99
  const line = compact
66
- ? `${role}${nameStr} [ref=${ref}]${attrStr}`
67
- : `- ${role}${nameStr} [ref=${ref}]${attrStr}`;
100
+ ? `${role}${nameStr} [ref=${ref}]${attrStr}${editorStr}`
101
+ : `- ${role}${nameStr} [ref=${ref}]${attrStr}${editorStr}`;
68
102
  lines.push(line);
69
103
  }
70
104
  return { refs: lines.join('\n'), nodeMap };
@@ -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;
@@ -66,7 +99,7 @@ export declare class BrowserService {
66
99
  nodeMap: Map<number, RefNode>;
67
100
  }>;
68
101
  click(taskId: string, ref: number, tabHint?: string): Promise<void>;
69
- type(taskId: string, ref: number, text: string, tabHint?: string): Promise<void>;
102
+ type(taskId: string, ref: number, text: string, tabHint?: string, clear?: boolean): Promise<void>;
70
103
  press(taskId: string, key: string, tabHint?: string): Promise<void>;
71
104
  hover(taskId: string, ref: number, tabHint?: string): Promise<void>;
72
105
  scroll(taskId: string, deltaX: number, deltaY: number, atX?: number, atY?: number, tabHint?: string): Promise<void>;
@@ -8,7 +8,89 @@ import { connectSSH } from './drivers/ssh.js';
8
8
  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
+ import { typeEditorText } from './editor.js';
11
12
  import { emit } from '../events.js';
13
+ /**
14
+ * Parse a `targetFilter` string into its kind + value, or return `null`
15
+ * when the input is missing or malformed. Filter syntax:
16
+ * - `url:<substring>` — picks the first page target whose URL contains the substring
17
+ * - `title:<substring>` — picks the first page target whose title contains the substring
18
+ *
19
+ * The match is case-insensitive on both sides because Electron apps
20
+ * frequently lowercase or title-case their target metadata in unpredictable ways.
21
+ */
22
+ export function parseTargetFilter(filter) {
23
+ if (!filter)
24
+ return null;
25
+ const idx = filter.indexOf(':');
26
+ if (idx <= 0)
27
+ return null;
28
+ const kind = filter.slice(0, idx).trim().toLowerCase();
29
+ // Strip whitespace around the value so `url: https://x` (with a copy-pasted
30
+ // space after the colon) doesn't silently fail to match — `.includes(' x')`
31
+ // never hits a URL because URLs don't contain spaces.
32
+ const value = filter.slice(idx + 1).trim();
33
+ if (kind !== 'url' && kind !== 'title')
34
+ return null;
35
+ if (!value)
36
+ return null;
37
+ return { kind, value };
38
+ }
39
+ /**
40
+ * URLs that the skip-invisible heuristic excludes when no explicit filter
41
+ * matches. These are page targets Electron apps ship for housekeeping;
42
+ * picking one means screenshots come back blank.
43
+ */
44
+ const INVISIBLE_URL_PATTERNS = [
45
+ /^about:blank$/i,
46
+ /^file:\/\//i,
47
+ /\/_desktop-background-service(\?|$|\/)/i,
48
+ /\/_internal(\?|$|\/)/i,
49
+ /\/_background(\?|$|\/)/i,
50
+ ];
51
+ function isLikelyInvisible(url) {
52
+ if (!url)
53
+ return true;
54
+ return INVISIBLE_URL_PATTERNS.some((re) => re.test(url));
55
+ }
56
+ /**
57
+ * Choose the CDP page target that represents the visible UI.
58
+ *
59
+ * Order:
60
+ * 1. If `filter` is set and parseable, narrow to page targets matching it
61
+ * (case-insensitive substring). Among matches, prefer one that is not in
62
+ * `INVISIBLE_URL_PATTERNS` — this is the tiebreaker that makes a coarse
63
+ * filter like `url:https://www.canva.com/` skip the background service
64
+ * (`https://www.canva.com/_desktop-background-service` *also* matches the
65
+ * substring). If every match is invisible, return the first match so the
66
+ * caller still gets something rather than silently falling through.
67
+ * An explicit filter that finds *no* match returns `undefined` — callers
68
+ * should surface this as an error rather than create an orphan window.
69
+ * 2. If `filter` is unset (or unparseable), apply the skip-invisible heuristic
70
+ * across all page targets.
71
+ * 3. As a last resort, return the first page target.
72
+ */
73
+ export function pickWindowTarget(targets, filter) {
74
+ const pages = targets.filter((t) => t.type === 'page');
75
+ if (pages.length === 0)
76
+ return undefined;
77
+ const parsed = parseTargetFilter(filter);
78
+ if (parsed) {
79
+ const needle = parsed.value.toLowerCase();
80
+ const matches = pages.filter((t) => {
81
+ const hay = (parsed.kind === 'url' ? t.url : t.title) ?? '';
82
+ return hay.toLowerCase().includes(needle);
83
+ });
84
+ if (matches.length === 0)
85
+ return undefined;
86
+ const visible = matches.find((t) => !isLikelyInvisible(t.url));
87
+ return visible ?? matches[0];
88
+ }
89
+ const visible = pages.find((t) => !isLikelyInvisible(t.url));
90
+ if (visible)
91
+ return visible;
92
+ return pages[0];
93
+ }
12
94
  export class BrowserService {
13
95
  connections = new Map();
14
96
  forkingProfiles = new Set();
@@ -351,7 +433,25 @@ export class BrowserService {
351
433
  throw new Error(`Tab ${shortId} not found`);
352
434
  }
353
435
  const sessionId = await this.getSessionId(conn, target.targetId);
354
- const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId));
436
+ // `awaitPromise: true` lets callers write `evaluate '(async () => {...})()'`
437
+ // and get the resolved value back instead of a stringified Promise. This
438
+ // is essential for any flow that needs sub-step waits inside the page
439
+ // (e.g. driving a multi-step modal where each step needs React to settle
440
+ // before the next call). Without it, the shell-side workaround is to
441
+ // chain N separate `evaluate` calls with `sleep` between them, which
442
+ // races against the page's own state machine.
443
+ //
444
+ // `exceptionDetails` is surfaced as a thrown error so a rejected promise
445
+ // or a thrown error inside the expression doesn't silently return `undefined`.
446
+ const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId));
447
+ if (result.exceptionDetails) {
448
+ const ex = result.exceptionDetails;
449
+ const msg = ex.exception?.description ??
450
+ (typeof ex.exception?.value === 'string' ? ex.exception.value : undefined) ??
451
+ ex.text ??
452
+ 'evaluate failed';
453
+ throw new Error(msg);
454
+ }
355
455
  return result.result.value;
356
456
  }
357
457
  async screenshot(taskId, tabHint, outputPath) {
@@ -403,7 +503,7 @@ export class BrowserService {
403
503
  const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
404
504
  await clickAtCoords(conn.cdp, sessionId, x, y);
405
505
  }
406
- async type(taskId, ref, text, tabHint) {
506
+ async type(taskId, ref, text, tabHint, clear) {
407
507
  const { conn, task } = await this.findTask(taskId);
408
508
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
409
509
  const cdpTargetId = this.getCdpTargetId(task, shortId);
@@ -415,10 +515,15 @@ export class BrowserService {
415
515
  const node = nodeMap.get(ref);
416
516
  if (!node)
417
517
  throw new Error(`Ref ${ref} not found`);
418
- if (node.backendNodeId) {
419
- await focusNode(conn.cdp, sessionId, node.backendNodeId);
518
+ if (node.editor) {
519
+ await typeEditorText(conn.cdp, sessionId, node, text, clear);
520
+ }
521
+ else {
522
+ if (node.backendNodeId) {
523
+ await focusNode(conn.cdp, sessionId, node.backendNodeId);
524
+ }
525
+ await typeText(conn.cdp, sessionId, text);
420
526
  }
421
- await typeText(conn.cdp, sessionId, text);
422
527
  }
423
528
  async press(taskId, key, tabHint) {
424
529
  const { conn, task } = await this.findTask(taskId);
@@ -839,6 +944,7 @@ export class BrowserService {
839
944
  port,
840
945
  pid,
841
946
  electron: true,
947
+ targetFilter: profile.targetFilter,
842
948
  forkedFrom: profile.name,
843
949
  tasks: new Map(),
844
950
  sessionCache: new Map(),
@@ -860,6 +966,7 @@ export class BrowserService {
860
966
  port: existingInfo.port,
861
967
  pid: existingInfo.pid,
862
968
  electron: profile.electron,
969
+ targetFilter: profile.targetFilter,
863
970
  tasks,
864
971
  sessionCache: new Map(),
865
972
  };
@@ -889,6 +996,7 @@ export class BrowserService {
889
996
  port: conn.port,
890
997
  pid: conn.pid,
891
998
  electron: profile.electron,
999
+ targetFilter: profile.targetFilter,
892
1000
  tasks: conn.pid === 0 ? this.loadTaskState(profile.name) : new Map(),
893
1001
  sessionCache: new Map(),
894
1002
  };
@@ -901,6 +1009,7 @@ export class BrowserService {
901
1009
  port: conn.port,
902
1010
  pid: conn.pid,
903
1011
  electron: profile.electron,
1012
+ targetFilter: profile.targetFilter,
904
1013
  tasks: new Map(),
905
1014
  sessionCache: new Map(),
906
1015
  };
@@ -914,6 +1023,7 @@ export class BrowserService {
914
1023
  port: 0,
915
1024
  pid: 0,
916
1025
  electron: profile.electron,
1026
+ targetFilter: profile.targetFilter,
917
1027
  tasks: this.loadTaskState(profile.name),
918
1028
  sessionCache: new Map(),
919
1029
  };
@@ -930,6 +1040,7 @@ export class BrowserService {
930
1040
  port,
931
1041
  pid: 0,
932
1042
  electron: profile.electron,
1043
+ targetFilter: profile.targetFilter,
933
1044
  tasks: this.loadTaskState(profile.name),
934
1045
  sessionCache: new Map(),
935
1046
  };
@@ -951,11 +1062,24 @@ export class BrowserService {
951
1062
  }
952
1063
  // Check if browser already has a page target we can use
953
1064
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
954
- const existing = targetInfos.find((t) => t.type === 'page');
1065
+ const existing = pickWindowTarget(targetInfos, conn.targetFilter);
955
1066
  if (existing) {
956
1067
  conn.windowId = existing.targetId;
957
1068
  return existing.targetId;
958
1069
  }
1070
+ // If we have an explicit filter, `pickWindowTarget` returns undefined when nothing
1071
+ // matches. That almost always means the profile is misconfigured (typo in the
1072
+ // filter, target hasn't loaded yet, app version moved the URL). Falling through
1073
+ // to `Target.createTarget` would silently create an orphan tab the user can't see.
1074
+ // Surface the failure instead, with the candidate list so the fix is obvious.
1075
+ if (parseTargetFilter(conn.targetFilter)) {
1076
+ const candidates = targetInfos
1077
+ .filter((t) => t.type === 'page')
1078
+ .map((t) => ` - url=${t.url ?? ''} title=${t.title ?? ''}`)
1079
+ .join('\n');
1080
+ throw new Error(`Target filter ${JSON.stringify(conn.targetFilter)} matched no page target.\n` +
1081
+ `Available page targets:\n${candidates || ' (none)'}`);
1082
+ }
959
1083
  // First ever use - create window
960
1084
  const result = (await conn.cdp.send('Target.createTarget', {
961
1085
  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[];
@@ -119,6 +129,7 @@ export interface IPCResponse {
119
129
  result?: unknown;
120
130
  path?: string;
121
131
  refs?: string;
132
+ nodes?: RefNodeJson[];
122
133
  port?: number;
123
134
  pid?: number;
124
135
  logs?: ConsoleEntry[];
@@ -150,6 +161,13 @@ export interface NetworkRequest {
150
161
  mimeType?: string;
151
162
  timestamp: number;
152
163
  }
164
+ export interface RefNodeJson {
165
+ ref: number;
166
+ role: string;
167
+ name: string;
168
+ attrs: string[];
169
+ editor?: string;
170
+ }
153
171
  export interface DeviceDescriptor {
154
172
  width: number;
155
173
  height: number;
@@ -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.2",
3
+ "version": "1.17.4",
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",
@@ -87,6 +87,7 @@
87
87
  "@types/diff": "^6.0.0",
88
88
  "@types/marked-terminal": "^6.1.1",
89
89
  "@types/node": "^22.0.0",
90
+ "playwright": "^1.44.0",
90
91
  "tsx": "^4.19.0",
91
92
  "typescript": "^5.5.0",
92
93
  "vitest": "^2.0.0"