@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 +17 -0
- package/dist/commands/browser.js +46 -2
- package/dist/commands/secrets.js +75 -7
- package/dist/lib/browser/editor.d.ts +3 -0
- package/dist/lib/browser/editor.js +50 -0
- package/dist/lib/browser/input.js +42 -5
- package/dist/lib/browser/ipc.js +9 -3
- package/dist/lib/browser/profiles.js +3 -0
- package/dist/lib/browser/refs.d.ts +1 -0
- package/dist/lib/browser/refs.js +36 -2
- package/dist/lib/browser/service.d.ts +34 -1
- package/dist/lib/browser/service.js +130 -6
- package/dist/lib/browser/types.d.ts +18 -0
- package/dist/lib/secrets/bundles.d.ts +6 -0
- package/dist/lib/secrets/bundles.js +40 -0
- package/dist/lib/types.d.ts +10 -0
- package/package.json +2 -1
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**
|
package/dist/commands/browser.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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(
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/lib/browser/refs.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
419
|
-
await
|
|
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
|
|
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);
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -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.
|
|
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"
|