arc-control-mcp 0.3.0
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 +255 -0
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/package.json +52 -0
- package/src/index.js +123 -0
- package/src/jxa.js +243 -0
- package/src/page-lib.js +219 -0
- package/src/registry.js +99 -0
- package/src/state.js +216 -0
- package/src/tools/content.js +217 -0
- package/src/tools/interact.js +321 -0
- package/src/tools/navigation.js +302 -0
- package/src/tools/schema.js +48 -0
- package/src/tools/scripting.js +217 -0
- package/src/tools/shared.js +100 -0
- package/src/tools/spaces.js +66 -0
- package/src/tools/tabs.js +189 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { ArcError } from '../jxa.js';
|
|
2
|
+
import { z, TAB_ID, SELECTOR, VERBOSE, EXACT, NTH, timeoutMs, MAX_CALLER_TIMEOUT_MS } from './schema.js';
|
|
3
|
+
import { write, read, runPage, sleep } from './shared.js';
|
|
4
|
+
|
|
5
|
+
const POLL_MS = 250;
|
|
6
|
+
const DEFAULT_WAIT_MS = 10000;
|
|
7
|
+
// An MCP client abandons a request after 60s (the SDK's own default), and once
|
|
8
|
+
// it does, the timedOut payload with waitedMs and the last counts is thrown
|
|
9
|
+
// away. So a caller-supplied wait is capped well inside that ceiling, by the
|
|
10
|
+
// MAX_CALLER_TIMEOUT_MS that schema.js holds for every waiting tool. Progress
|
|
11
|
+
// notifications would not buy more time: a client MAY reset its clock on
|
|
12
|
+
// progress and mostly does not.
|
|
13
|
+
const DEFAULT_SCROLL_PX = 800;
|
|
14
|
+
const MAX_OPTIONS_LISTED = 25;
|
|
15
|
+
const TARGET_ATTR_CHARS = 80;
|
|
16
|
+
|
|
17
|
+
// The shared exact flag, narrowed to the wording these tools have always
|
|
18
|
+
// advertised: the "no effect on CSS selectors" half is what stops a model
|
|
19
|
+
// passing it blind and then wondering why nothing changed.
|
|
20
|
+
const MATCH_EXACT = EXACT.describe(
|
|
21
|
+
'For "text=" selectors, require the whole trimmed text to equal the label instead of containing it. No effect on CSS selectors.'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// Both are interpolated straight into page scripts, so they have to be literals.
|
|
25
|
+
const matchOpts = (args) => JSON.stringify({ exact: args.exact === true });
|
|
26
|
+
const verboseFlag = (args) => String(args.verbose === true);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The spec asks a receiver of notifications/cancelled to stop work and release
|
|
30
|
+
* resources. Each poll spawns an osascript process against the user's Arc, so a
|
|
31
|
+
* loop that ignores the signal keeps prodding their browser for a result nobody
|
|
32
|
+
* will read. extra is absent when a handler is called internally, for instance
|
|
33
|
+
* by batch.
|
|
34
|
+
*/
|
|
35
|
+
function throwIfCancelled(extra) {
|
|
36
|
+
if (extra?.signal?.aborted) throw new ArcError('Cancelled by the caller.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TEXT_NOTE =
|
|
40
|
+
'"text=Label" matches on visible text as a substring, with exact matches ranked first, so a short label also matches longer ones. ' +
|
|
41
|
+
'Check the returned "matches" count, and pass exact when it is above 1.';
|
|
42
|
+
|
|
43
|
+
export const tools = [
|
|
44
|
+
{
|
|
45
|
+
name: 'click',
|
|
46
|
+
description:
|
|
47
|
+
'Click an element. Accepts a CSS selector or "text=Label". Scrolls it into view and dispatches a real pointer sequence, so framework handlers fire. ' +
|
|
48
|
+
TEXT_NOTE +
|
|
49
|
+
' Returns urlBefore, the url as it was immediately before the click: the tab snapshot can be taken before a navigation settles, so follow with wait_for_load when the click navigates.',
|
|
50
|
+
input: z.object({
|
|
51
|
+
selector: SELECTOR,
|
|
52
|
+
tab_id: TAB_ID.optional(),
|
|
53
|
+
nth: NTH.describe('Which match to click when several exist, 0-based. Order is exact text matches first, then substring matches, each in DOM order.'),
|
|
54
|
+
exact: MATCH_EXACT,
|
|
55
|
+
verbose: VERBOSE
|
|
56
|
+
}),
|
|
57
|
+
annotations: write('Click')
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'fill',
|
|
61
|
+
description:
|
|
62
|
+
'Set the value of an input, textarea or contenteditable. Uses the native setter and fires input and change, so React and similar frameworks register it. ' +
|
|
63
|
+
'Fails with an error naming the tag when the target cannot be filled: a heading or other non-input, a disabled or readonly field, or a <select> (use select_option for those). ' +
|
|
64
|
+
TEXT_NOTE,
|
|
65
|
+
input: z.object({
|
|
66
|
+
selector: SELECTOR,
|
|
67
|
+
value: z.string().describe('Value to set'),
|
|
68
|
+
tab_id: TAB_ID.optional(),
|
|
69
|
+
nth: NTH.describe('Which match to fill, 0-based'),
|
|
70
|
+
submit: z
|
|
71
|
+
.boolean()
|
|
72
|
+
.default(false)
|
|
73
|
+
.describe(
|
|
74
|
+
'Press Enter and request form submit afterwards. The tab usually navigates, so follow with wait_for_load: the returned tab snapshot may predate the navigation, and urlBefore reports the url from just before the key press.'
|
|
75
|
+
),
|
|
76
|
+
exact: MATCH_EXACT,
|
|
77
|
+
verbose: VERBOSE
|
|
78
|
+
}),
|
|
79
|
+
annotations: write('Fill Field')
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'select_option',
|
|
83
|
+
description:
|
|
84
|
+
'Choose an option in a select element, by exact option value or exact visible label. ' +
|
|
85
|
+
'When nothing matches, the failure lists the options that do exist, so the next call can pick a real one.',
|
|
86
|
+
input: z.object({
|
|
87
|
+
selector: SELECTOR.describe('CSS selector for the <select>. "text=" cannot reach a select element, so use a CSS selector here.'),
|
|
88
|
+
option: z.string().describe('Option value or visible text, matched exactly after trimming'),
|
|
89
|
+
tab_id: TAB_ID.optional()
|
|
90
|
+
}),
|
|
91
|
+
annotations: write('Select Option')
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'press_key',
|
|
95
|
+
description:
|
|
96
|
+
'Dispatch a key press to an element, or to the focused element when no selector is given. Handles named keys (Enter, Escape, Tab, ArrowDown) and single printable characters. ' +
|
|
97
|
+
'Returns only a minimal identity for the element that received the key (tag plus whichever of id, name, type and aria-label exist); use query_elements when you need the full picture.',
|
|
98
|
+
input: z.object({
|
|
99
|
+
key: z.string().describe('Key name, for example Enter, Escape, Tab, ArrowDown, or a single printable character'),
|
|
100
|
+
selector: SELECTOR.optional(),
|
|
101
|
+
tab_id: TAB_ID.optional(),
|
|
102
|
+
exact: MATCH_EXACT
|
|
103
|
+
}),
|
|
104
|
+
annotations: write('Press Key')
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'scroll',
|
|
108
|
+
description: 'Scroll the page, or scroll an element into view. Page scrolls report scrollY, pageHeight and viewport, so you can tell how much is left.',
|
|
109
|
+
input: z.object({
|
|
110
|
+
tab_id: TAB_ID.optional(),
|
|
111
|
+
selector: SELECTOR.describe(
|
|
112
|
+
'Scroll this element into view instead of scrolling the page. CSS selector, or "text=Label" (substring, exact matches ranked first).'
|
|
113
|
+
).optional(),
|
|
114
|
+
direction: z.enum(['down', 'up', 'top', 'bottom']).default('down').describe('Page scroll direction'),
|
|
115
|
+
amount: z.number().default(DEFAULT_SCROLL_PX).describe('Pixels to scroll for up and down'),
|
|
116
|
+
exact: MATCH_EXACT,
|
|
117
|
+
verbose: VERBOSE
|
|
118
|
+
}),
|
|
119
|
+
annotations: write('Scroll', { idempotent: true })
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'wait_for_selector',
|
|
123
|
+
description:
|
|
124
|
+
'Poll until an element appears, becomes visible, or disappears. Use after a click that loads content. ' +
|
|
125
|
+
'On timeout it says so explicitly with waitedMs and the last counts, and a broken selector or page error fails straight away instead of burning the whole timeout. ' +
|
|
126
|
+
'The visible state ignores screen-reader clipping (boxes under 2x2 px, inset clip-path), which nothing can actually click.',
|
|
127
|
+
input: z.object({
|
|
128
|
+
selector: SELECTOR,
|
|
129
|
+
tab_id: TAB_ID.optional(),
|
|
130
|
+
state: z.enum(['present', 'visible', 'absent']).default('visible').describe('Condition to wait for'),
|
|
131
|
+
timeout_ms: timeoutMs(DEFAULT_WAIT_MS, 'Give up after this long. Every call returns the current counts, so repeated short waits tell you more than one long one.'),
|
|
132
|
+
exact: MATCH_EXACT,
|
|
133
|
+
verbose: VERBOSE
|
|
134
|
+
}),
|
|
135
|
+
annotations: read('Wait For Selector')
|
|
136
|
+
}
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
export const handlers = {
|
|
140
|
+
click: async (args) => {
|
|
141
|
+
const { result, tab } = await runPage(
|
|
142
|
+
args,
|
|
143
|
+
`var els = A.all(${JSON.stringify(args.selector)}, null, ${matchOpts(args)});
|
|
144
|
+
var el = els[${args.nth ?? 0}];
|
|
145
|
+
if (!el) return { error: 'no_match', matches: els.length };
|
|
146
|
+
var before = location.href;
|
|
147
|
+
A.click(el);
|
|
148
|
+
return { clicked: A.describe(el, ${verboseFlag(args)}), matches: els.length, urlBefore: before };`
|
|
149
|
+
);
|
|
150
|
+
if (result?.error === 'no_match') {
|
|
151
|
+
return { ok: false, error: `No element matches ${args.selector}`, matches: result.matches, tab };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, ...result, tab };
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
// A.setValue throws for anything unfillable and runPage turns that into an
|
|
157
|
+
// ArcError naming the tag, so there is deliberately no catch here: only a
|
|
158
|
+
// genuinely absent selector gets the soft failure below.
|
|
159
|
+
fill: async (args) => {
|
|
160
|
+
const submit = args.submit === true;
|
|
161
|
+
const { result, tab } = await runPage(
|
|
162
|
+
args,
|
|
163
|
+
`var els = A.all(${JSON.stringify(args.selector)}, null, ${matchOpts(args)});
|
|
164
|
+
var el = els[${args.nth ?? 0}];
|
|
165
|
+
if (!el) return { error: 'no_match', matches: els.length };
|
|
166
|
+
A.setValue(el, ${JSON.stringify(args.value)});
|
|
167
|
+
// Describe before submitting, so filled.value shows what landed even if
|
|
168
|
+
// the form resets or navigates.
|
|
169
|
+
var out = { filled: A.describe(el, ${verboseFlag(args)}), matches: els.length };
|
|
170
|
+
${submit
|
|
171
|
+
? `out.urlBefore = location.href;
|
|
172
|
+
A.key(el, 'Enter');
|
|
173
|
+
if (el.form && el.form.requestSubmit) { try { el.form.requestSubmit(); } catch (e) {} }`
|
|
174
|
+
: ''}
|
|
175
|
+
return out;`
|
|
176
|
+
);
|
|
177
|
+
if (result?.error === 'no_match') {
|
|
178
|
+
return { ok: false, error: `No element matches ${args.selector}`, matches: result.matches, tab };
|
|
179
|
+
}
|
|
180
|
+
if (submit) {
|
|
181
|
+
return { ok: true, ...result, submitted: true, note: 'The tab may still be navigating. Call wait_for_load before reading the page.', tab };
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, ...result, tab };
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
select_option: async (args) => {
|
|
187
|
+
const { result, tab } = await runPage(
|
|
188
|
+
args,
|
|
189
|
+
`var el = A.one(${JSON.stringify(args.selector)});
|
|
190
|
+
if (!el) return { error: 'no_match' };
|
|
191
|
+
var tag = el.tagName.toLowerCase();
|
|
192
|
+
if (tag !== 'select') return { error: 'not_select', tag: tag };
|
|
193
|
+
var want = ${JSON.stringify(args.option)};
|
|
194
|
+
var chosen = null;
|
|
195
|
+
for (var i = 0; i < el.options.length; i++) {
|
|
196
|
+
var o = el.options[i];
|
|
197
|
+
if (o.value === want || (o.text || '').trim() === want) {
|
|
198
|
+
el.selectedIndex = i;
|
|
199
|
+
chosen = { value: o.value, text: (o.text || '').trim() };
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!chosen) {
|
|
204
|
+
// Listing what is there turns a dead end into a recoverable next call.
|
|
205
|
+
var available = [];
|
|
206
|
+
for (var j = 0; j < el.options.length && j < ${MAX_OPTIONS_LISTED}; j++) {
|
|
207
|
+
available.push({ value: el.options[j].value, text: (el.options[j].text || '').trim() });
|
|
208
|
+
}
|
|
209
|
+
return { error: 'no_option', available: available, total: el.options.length };
|
|
210
|
+
}
|
|
211
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
212
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
213
|
+
return { selected: chosen };`
|
|
214
|
+
);
|
|
215
|
+
if (result?.error === 'no_match') return { ok: false, error: `No element matches ${args.selector}`, tab };
|
|
216
|
+
if (result?.error === 'not_select') {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: `Selector ${args.selector} resolves to <${result.tag}>, not <select>. Use fill for a text input, or click for a custom dropdown.`,
|
|
220
|
+
tab
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (result?.error === 'no_option') {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
error: `No option in ${args.selector} has the value or visible text "${args.option}". Pick one from available, which lists ${result.available.length} of the ${result.total} real options with their value and text.`,
|
|
227
|
+
available: result.available,
|
|
228
|
+
total: result.total,
|
|
229
|
+
tab
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return { ok: true, ...result, tab };
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
press_key: async (args) => {
|
|
236
|
+
const { result, tab } = await runPage(
|
|
237
|
+
args,
|
|
238
|
+
`var sel = ${JSON.stringify(args.selector || null)};
|
|
239
|
+
var el = sel ? A.one(sel, 0, ${matchOpts(args)}) : (document.activeElement || document.body);
|
|
240
|
+
if (!el) return { error: 'no_match' };
|
|
241
|
+
A.key(el, ${JSON.stringify(args.key)});
|
|
242
|
+
// Identity only: a full describe of a fallback document.body would drag
|
|
243
|
+
// the page's entire innerText into the response.
|
|
244
|
+
var target = { tag: el.tagName.toLowerCase() };
|
|
245
|
+
var names = ['id', 'name', 'type', 'aria-label'];
|
|
246
|
+
for (var i = 0; i < names.length; i++) {
|
|
247
|
+
var v = el.getAttribute(names[i]);
|
|
248
|
+
if (v) target[names[i]] = v.slice(0, ${TARGET_ATTR_CHARS});
|
|
249
|
+
}
|
|
250
|
+
return { key: ${JSON.stringify(args.key)}, target: target, usedFocusedElement: !sel };`
|
|
251
|
+
);
|
|
252
|
+
if (result?.error === 'no_match') {
|
|
253
|
+
// Without a selector the miss means the document had nothing to aim at.
|
|
254
|
+
const why = args.selector
|
|
255
|
+
? `No element matches ${args.selector}`
|
|
256
|
+
: 'The page has no focused element and no body to fall back to.';
|
|
257
|
+
return { ok: false, error: why, tab };
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, ...result, tab };
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
scroll: async (args) => {
|
|
263
|
+
const { result, tab } = await runPage(
|
|
264
|
+
args,
|
|
265
|
+
`var sel = ${JSON.stringify(args.selector || null)};
|
|
266
|
+
if (sel) {
|
|
267
|
+
var el = A.one(sel, 0, ${matchOpts(args)});
|
|
268
|
+
if (!el) return { error: 'no_match' };
|
|
269
|
+
el.scrollIntoView({ block: 'center' });
|
|
270
|
+
return { scrolledTo: A.describe(el, ${verboseFlag(args)}) };
|
|
271
|
+
}
|
|
272
|
+
var dir = ${JSON.stringify(args.direction || 'down')};
|
|
273
|
+
var amount = ${args.amount ?? DEFAULT_SCROLL_PX};
|
|
274
|
+
if (dir === 'top') window.scrollTo(0, 0);
|
|
275
|
+
else if (dir === 'bottom') window.scrollTo(0, document.body.scrollHeight);
|
|
276
|
+
else window.scrollBy(0, dir === 'up' ? -amount : amount);
|
|
277
|
+
return { scrollY: window.scrollY, pageHeight: document.body.scrollHeight, viewport: window.innerHeight };`
|
|
278
|
+
);
|
|
279
|
+
if (result?.error === 'no_match') return { ok: false, error: `No element matches ${args.selector}`, tab };
|
|
280
|
+
return { ok: true, ...result, tab };
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
// runPage throws on a page-script failure, and that is left to propagate: a
|
|
284
|
+
// bad selector is a bad selector on every poll, so retrying it to the
|
|
285
|
+
// deadline would only turn a clear error into a vague timeout.
|
|
286
|
+
wait_for_selector: async (args, extra) => {
|
|
287
|
+
const timeout = Math.min(args.timeout_ms ?? DEFAULT_WAIT_MS, MAX_CALLER_TIMEOUT_MS);
|
|
288
|
+
const want = args.state || 'visible';
|
|
289
|
+
const started = Date.now();
|
|
290
|
+
let last = null;
|
|
291
|
+
|
|
292
|
+
while (Date.now() - started < timeout) {
|
|
293
|
+
throwIfCancelled(extra);
|
|
294
|
+
const { result, tab } = await runPage(
|
|
295
|
+
args,
|
|
296
|
+
`var els = A.all(${JSON.stringify(args.selector)}, null, ${matchOpts(args)});
|
|
297
|
+
var vis = 0;
|
|
298
|
+
for (var i = 0; i < els.length; i++) if (A.visible(els[i])) vis++;
|
|
299
|
+
return { count: els.length, visible: vis, first: els[0] ? A.describe(els[0], ${verboseFlag(args)}) : null };`,
|
|
300
|
+
// Bound the probe by what is left, so a wedged call cannot push the
|
|
301
|
+
// tool past the client's timeout.
|
|
302
|
+
Math.max(timeout - (Date.now() - started), 1500)
|
|
303
|
+
);
|
|
304
|
+
last = { ...result, tab };
|
|
305
|
+
const done =
|
|
306
|
+
(want === 'present' && result.count > 0) ||
|
|
307
|
+
(want === 'visible' && result.visible > 0) ||
|
|
308
|
+
(want === 'absent' && result.count === 0);
|
|
309
|
+
if (done) return { ok: true, state: want, waitedMs: Date.now() - started, ...last };
|
|
310
|
+
await sleep(POLL_MS);
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
timedOut: true,
|
|
315
|
+
state: want,
|
|
316
|
+
waitedMs: Date.now() - started,
|
|
317
|
+
...last,
|
|
318
|
+
note: `Selector "${args.selector}" was not ${want} within ${timeout}ms.`
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
};
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { ArcError } from '../jxa.js';
|
|
2
|
+
import { z, TAB_ID, timeoutMs, MAX_CALLER_TIMEOUT_MS } from './schema.js';
|
|
3
|
+
import { read, write, runTab, runPage, sleep, state } from './shared.js';
|
|
4
|
+
|
|
5
|
+
const POLL_MS = 250;
|
|
6
|
+
const LOAD_TIMEOUT_MS = 15000;
|
|
7
|
+
// An MCP client gives up on a request after 60s (the SDK's own default), and a
|
|
8
|
+
// call that outlives that loses its result: the caller sees RequestTimeout
|
|
9
|
+
// instead of the tab that opened fine, or the timedOut payload that explains
|
|
10
|
+
// what the page was doing. So open_url's launch budget plus the longest load
|
|
11
|
+
// wait it can be asked for has to stay clearly inside 60s. Progress
|
|
12
|
+
// notifications do not help: a client MAY reset its clock on progress and
|
|
13
|
+
// mostly does not, so the budget itself is the fix. The ceiling on that wait is
|
|
14
|
+
// MAX_CALLER_TIMEOUT_MS, which schema.js holds for every waiting tool.
|
|
15
|
+
const OPEN_TIMEOUT_MS = 25000;
|
|
16
|
+
// A back, forward or reload commits in well under a second. Giving the move
|
|
17
|
+
// check a short budget of its own means "there is no entry that way" answers
|
|
18
|
+
// quickly instead of burning the whole load timeout on a tab that never moved.
|
|
19
|
+
const MOVE_TIMEOUT_MS = 5000;
|
|
20
|
+
|
|
21
|
+
// Every caller-supplied wait goes through here, so no argument can push a call
|
|
22
|
+
// past the client's deadline.
|
|
23
|
+
const loadTimeout = (args) => Math.min(args.timeout_ms ?? LOAD_TIMEOUT_MS, MAX_CALLER_TIMEOUT_MS);
|
|
24
|
+
|
|
25
|
+
// Capped so a missing history entry answers fast, but never longer than the
|
|
26
|
+
// caller asked to wait in total.
|
|
27
|
+
const moveTimeout = (args) => Math.min(loadTimeout(args), MOVE_TIMEOUT_MS);
|
|
28
|
+
|
|
29
|
+
// Every tool here navigates, so they all take the same waiting controls.
|
|
30
|
+
const WAIT_OPTIONS = {
|
|
31
|
+
wait_until_loaded: z.boolean().default(true).describe('Wait for the page to finish loading before returning'),
|
|
32
|
+
timeout_ms: timeoutMs(LOAD_TIMEOUT_MS, 'How long to wait for loading.')
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The spec asks a receiver of notifications/cancelled to stop work and release
|
|
37
|
+
* resources. Every poll here spawns an osascript process against the user's
|
|
38
|
+
* Arc, so a loop that ignores the signal keeps prodding their browser for a
|
|
39
|
+
* result nobody will read. extra is absent when a handler is called internally,
|
|
40
|
+
* for instance by batch.
|
|
41
|
+
*/
|
|
42
|
+
function throwIfCancelled(extra) {
|
|
43
|
+
if (extra?.signal?.aborted) throw new ArcError('Cancelled by the caller.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseUrl(url) {
|
|
47
|
+
try {
|
|
48
|
+
return new URL(url);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new ArcError(`Not a valid URL: ${url}. Include a scheme, for example https://`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const tools = [
|
|
55
|
+
{
|
|
56
|
+
name: 'open_url',
|
|
57
|
+
description: 'Open a URL in Arc. Launches Arc if needed. New tabs go into the agent space when one exists, otherwise the main window. Arc auto-selects new tabs, so the previous selection is put back unless you pass activate.',
|
|
58
|
+
input: z.object({
|
|
59
|
+
url: z.string().describe('URL to open'),
|
|
60
|
+
new_tab: z.boolean().default(true).describe('Open a new tab. Set false to navigate an existing tab instead.'),
|
|
61
|
+
tab_id: TAB_ID.describe('With new_tab false, the tab to navigate. Required in that mode unless this agent already has a tab of its own, since navigating the tab the user is looking at is refused.').optional(),
|
|
62
|
+
space: z.string().describe('Space id or title to open into, overriding the agent space').optional(),
|
|
63
|
+
little_arc: z.boolean().default(false).describe('Open a Little Arc window. Fire and forget: Arc does not expose these afterwards.'),
|
|
64
|
+
activate: z.boolean().default(false).describe('Bring Arc to the front and leave the new tab selected'),
|
|
65
|
+
...WAIT_OPTIONS
|
|
66
|
+
}),
|
|
67
|
+
annotations: write('Open URL')
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'go_back',
|
|
71
|
+
description: "Navigate a tab back in history. Goes through the page, so it works on a background tab, which Arc's own back command does not, and the result is checked against the tab url rather than assumed.",
|
|
72
|
+
input: z.object({ tab_id: TAB_ID.optional(), ...WAIT_OPTIONS }),
|
|
73
|
+
annotations: write('Go Back')
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'go_forward',
|
|
77
|
+
description: 'Navigate a tab forward in history. Goes through the page and confirms the tab really moved before reporting success.',
|
|
78
|
+
input: z.object({ tab_id: TAB_ID.optional(), ...WAIT_OPTIONS }),
|
|
79
|
+
annotations: write('Go Forward')
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'reload_tab',
|
|
83
|
+
description:
|
|
84
|
+
'Reload a tab, then confirm the document really was replaced rather than assume it. Works on a background tab. ' +
|
|
85
|
+
'Takes wait_until_loaded and timeout_ms like the other navigating tools, and returns from, to and the tab. ' +
|
|
86
|
+
'Fails with ok false when the tab is still showing the document that was there before, which is what a slow server or a page holding on to unload looks like from outside.',
|
|
87
|
+
input: z.object({ tab_id: TAB_ID.optional(), ...WAIT_OPTIONS }),
|
|
88
|
+
annotations: write('Reload Tab', { idempotent: true })
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'wait_for_load',
|
|
92
|
+
description: 'Poll until a tab has finished loading and the document is ready. Use after an action that triggers navigation.',
|
|
93
|
+
input: z.object({
|
|
94
|
+
tab_id: TAB_ID.optional(),
|
|
95
|
+
timeout_ms: timeoutMs(LOAD_TIMEOUT_MS, 'Give up after this long.'),
|
|
96
|
+
url_contains: z.string().describe('Also wait until the url contains this substring').optional()
|
|
97
|
+
}),
|
|
98
|
+
annotations: read('Wait For Load')
|
|
99
|
+
}
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// A single osascript call must not outlive the wait it belongs to, or one
|
|
103
|
+
// wedged call started near the deadline stretches the whole tool past the
|
|
104
|
+
// client's timeout. Keep a floor so the last poll is still able to answer.
|
|
105
|
+
const MIN_PROBE_MS = 1500;
|
|
106
|
+
const probeBudget = (started, timeout) => Math.max(timeout - (Date.now() - started), MIN_PROBE_MS);
|
|
107
|
+
|
|
108
|
+
async function readyState(args, remainingMs) {
|
|
109
|
+
// timeOrigin identifies the document instance, which is how reload_tab tells
|
|
110
|
+
// a fresh page from the one it asked Arc to replace.
|
|
111
|
+
const { result, tab } = await runPage(
|
|
112
|
+
args,
|
|
113
|
+
`return { ready: document.readyState, url: location.href, origin: performance.timeOrigin };`,
|
|
114
|
+
remainingMs
|
|
115
|
+
);
|
|
116
|
+
return { ...result, tab };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The one polling loop behind every "has the tab actually moved" question.
|
|
121
|
+
* `settled` vetoes readings until the navigation is real, and `requireReady` is
|
|
122
|
+
* off for callers that only care that the move happened, not that it finished.
|
|
123
|
+
*/
|
|
124
|
+
async function waitForLoad(args, { settled, requireReady = true, extra } = {}) {
|
|
125
|
+
const timeout = loadTimeout(args);
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
let last = null;
|
|
128
|
+
while (Date.now() - started < timeout) {
|
|
129
|
+
throwIfCancelled(extra);
|
|
130
|
+
last = await readyState(args, probeBudget(started, timeout));
|
|
131
|
+
const urlOk = !args.url_contains || (last.url || '').includes(args.url_contains);
|
|
132
|
+
const isReady = !requireReady || last.ready === 'complete';
|
|
133
|
+
if (isReady && urlOk && (!settled || settled(last))) {
|
|
134
|
+
return { ok: true, ready: last.ready, url: last.url, tab: last.tab, waitedMs: Date.now() - started };
|
|
135
|
+
}
|
|
136
|
+
await sleep(POLL_MS);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
timedOut: true,
|
|
141
|
+
ready: last?.ready ?? null,
|
|
142
|
+
url: last?.url ?? null,
|
|
143
|
+
tab: last?.tab ?? null,
|
|
144
|
+
waitedMs: Date.now() - started,
|
|
145
|
+
note: `Still not ready after ${timeout}ms. The page may be slow, or blocked on a login or dialog.`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Optional wait for the new document, shared by all four navigating tools. A
|
|
151
|
+
* load timeout does not undo the navigation, so ok stays as the caller set it
|
|
152
|
+
* and the note is what warns that the content may only be half there.
|
|
153
|
+
*/
|
|
154
|
+
async function withLoad(args, result, extra) {
|
|
155
|
+
if (!result.tab || args.wait_until_loaded === false) return result;
|
|
156
|
+
|
|
157
|
+
const timeout = loadTimeout(args);
|
|
158
|
+
const loaded = await waitForLoad({ tab_id: result.tab.id, timeout_ms: timeout }, { extra });
|
|
159
|
+
const out = { ...result, loaded: loaded.ok, ready: loaded.ready, tab: loaded.tab ?? result.tab };
|
|
160
|
+
// A redirect can move the tab on again after the first url change, so keep
|
|
161
|
+
// the reported destination in step with the final snapshot.
|
|
162
|
+
if (result.to !== undefined && out.tab) out.to = out.tab.url;
|
|
163
|
+
if (!loaded.ok) {
|
|
164
|
+
out.note = `The tab did not finish loading within ${timeout}ms (readyState ${loaded.ready ?? 'unknown'}), so its content may be incomplete. Call wait_for_load to keep waiting, or read the page again afterwards.`;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Arc's own goBack does nothing on a background tab, while goForward and reload
|
|
170
|
+
// do work. Both directions go through the page instead, where history.back()
|
|
171
|
+
// moves a background tab instantly.
|
|
172
|
+
const historyStep = (direction) => {
|
|
173
|
+
const action = `went ${direction}`;
|
|
174
|
+
return async (args, extra) => {
|
|
175
|
+
const { result, tab } = await runPage(
|
|
176
|
+
args,
|
|
177
|
+
`var from = location.href;
|
|
178
|
+
if (history.length < 2) return { error: 'no_history', from: from, historyLength: history.length };
|
|
179
|
+
// Asynchronous: hand back the pre-move url and let Node confirm the move.
|
|
180
|
+
history.${direction}();
|
|
181
|
+
return { from: from, historyLength: history.length };`
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const { from, historyLength } = result;
|
|
185
|
+
if (result.error === 'no_history') {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
action,
|
|
189
|
+
from,
|
|
190
|
+
historyLength,
|
|
191
|
+
note: `history.length is ${historyLength}, so this tab has nothing to go ${direction} to.`,
|
|
192
|
+
tab
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const moved = await waitForLoad(
|
|
197
|
+
{ tab_id: tab.id, timeout_ms: moveTimeout(args) },
|
|
198
|
+
{ requireReady: false, settled: (reading) => reading.url !== from, extra }
|
|
199
|
+
);
|
|
200
|
+
if (!moved.ok) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
action,
|
|
204
|
+
from,
|
|
205
|
+
historyLength,
|
|
206
|
+
note: `The tab is still at ${from} ${moved.waitedMs}ms later, so nothing moved. Most likely there is no ${direction} entry in this tab's history (history.length is ${historyLength}, which counts both directions). A trapped navigation, a page still in flight, or a ${direction} entry with the same url would look the same from here.`,
|
|
207
|
+
tab: moved.tab ?? tab
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return withLoad(args, { ok: true, action, from, to: moved.url, historyLength, tab: moved.tab }, extra);
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export const handlers = {
|
|
216
|
+
open_url: async (args, extra) => {
|
|
217
|
+
parseUrl(args.url);
|
|
218
|
+
|
|
219
|
+
const result = await runTab(
|
|
220
|
+
args,
|
|
221
|
+
`if (!Arc.running()) { Arc.launch(); delay(1.5); }
|
|
222
|
+
if (P.activate) Arc.activate();
|
|
223
|
+
|
|
224
|
+
if (P.little_arc) {
|
|
225
|
+
Arc.tabs.push(Arc.Tab({ url: P.url }));
|
|
226
|
+
JSON.stringify({ ok: true, action: "opened in Little Arc", url: P.url, tab: null });
|
|
227
|
+
} else if (P.new_tab === false) {
|
|
228
|
+
const tab = target();
|
|
229
|
+
tab.url = P.url;
|
|
230
|
+
delay(0.4);
|
|
231
|
+
JSON.stringify({ ok: true, action: "navigated existing tab", tab: describe(tab) });
|
|
232
|
+
} else {
|
|
233
|
+
requireArc();
|
|
234
|
+
const named = P.space ? findSpace(P.space) : null;
|
|
235
|
+
if (P.space && !named) throw new Error("SPACE_NOT_FOUND:" + P.space);
|
|
236
|
+
const space = named || agentSpace();
|
|
237
|
+
const container = space || mainWindow();
|
|
238
|
+
const openedIn = space ? space.title() : "main window";
|
|
239
|
+
|
|
240
|
+
let restoreId = null;
|
|
241
|
+
if (!P.activate) { try { restoreId = mainWindow().activeTab.id(); } catch (e) {} }
|
|
242
|
+
|
|
243
|
+
const tab = Arc.Tab({ url: P.url });
|
|
244
|
+
container.tabs.push(tab);
|
|
245
|
+
delay(0.6);
|
|
246
|
+
const info = describe(tab);
|
|
247
|
+
|
|
248
|
+
// Undo only Arc's own auto-select; if the user moved elsewhere meanwhile, leave them there.
|
|
249
|
+
let restored = false;
|
|
250
|
+
let stoleFocus = false;
|
|
251
|
+
try { stoleFocus = mainWindow().activeTab.id() === info.id; } catch (e) {}
|
|
252
|
+
if (stoleFocus && restoreId && restoreId !== info.id) {
|
|
253
|
+
const previous = locate(restoreId);
|
|
254
|
+
if (previous) { Arc.select(previous); delay(0.3); restored = true; }
|
|
255
|
+
}
|
|
256
|
+
JSON.stringify({ ok: true, action: "opened new tab", openedIn: openedIn, tab: info, restoredUserTab: restored });
|
|
257
|
+
}`,
|
|
258
|
+
OPEN_TIMEOUT_MS
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (result.tab) {
|
|
262
|
+
state.claim(result.tab.id);
|
|
263
|
+
result.tab.mine = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return withLoad(args, result, extra);
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
go_back: historyStep('back'),
|
|
270
|
+
go_forward: historyStep('forward'),
|
|
271
|
+
|
|
272
|
+
reload_tab: async (args, extra) => {
|
|
273
|
+
// Arc.reload is the one Arc history command verified to work on a
|
|
274
|
+
// background tab, and an in-page location.reload() would tear the document
|
|
275
|
+
// down before the envelope could get back out.
|
|
276
|
+
const { result, tab } = await runPage(args, `return { from: location.href, origin: performance.timeOrigin };`);
|
|
277
|
+
const { from, origin } = result;
|
|
278
|
+
|
|
279
|
+
await runTab({ ...args, tab_id: tab.id }, `Arc.reload(target()); JSON.stringify({ ok: true });`);
|
|
280
|
+
|
|
281
|
+
// A reload always builds a new document and every document gets its own
|
|
282
|
+
// timeOrigin. The navigation entry type stays "reload" for that document's
|
|
283
|
+
// whole life, so it cannot tell this reload from an earlier one.
|
|
284
|
+
const moved = await waitForLoad(
|
|
285
|
+
{ tab_id: tab.id, timeout_ms: moveTimeout(args) },
|
|
286
|
+
{ requireReady: false, settled: (reading) => reading.origin !== origin, extra }
|
|
287
|
+
);
|
|
288
|
+
if (!moved.ok) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
action: 'reloaded',
|
|
292
|
+
from,
|
|
293
|
+
note: `Arc accepted the reload, but ${moved.waitedMs}ms on the tab is still showing the document that was there before it. A slow server may not have responded yet, or the page may be holding on to unload.`,
|
|
294
|
+
tab: moved.tab ?? tab
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return withLoad(args, { ok: true, action: 'reloaded', from, to: moved.url, tab: moved.tab }, extra);
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
wait_for_load: (args, extra) => waitForLoad(args, { extra })
|
|
302
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod input schemas. Zod is the single source of truth: the JSON Schema each
|
|
3
|
+
* tool advertises is generated from it in registry.js, and the same schema
|
|
4
|
+
* validates incoming arguments, so the two can never drift apart.
|
|
5
|
+
*
|
|
6
|
+
* Keep `z.number()` rather than `z.number().int()`. The int variant emits
|
|
7
|
+
* Number.MAX_SAFE_INTEGER bounds into the generated schema, which is noise in
|
|
8
|
+
* every tools/list response.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
export { z };
|
|
13
|
+
|
|
14
|
+
/** A caller-supplied wait must stay well under a client's 60s request timeout. */
|
|
15
|
+
export const MAX_CALLER_TIMEOUT_MS = 30000;
|
|
16
|
+
|
|
17
|
+
export const TAB_ID = z
|
|
18
|
+
.string()
|
|
19
|
+
.describe("Arc tab id from list_tabs. Omit to use this agent's current tab. A tool that changes a tab will not fall back to the tab the user is looking at, so pass this or call open_url first.");
|
|
20
|
+
|
|
21
|
+
export const SELECTOR = z
|
|
22
|
+
.string()
|
|
23
|
+
.describe('CSS selector, or "text=Some label" to match on visible text (case-insensitive substring, exact matches ranked first)');
|
|
24
|
+
|
|
25
|
+
export const VERBOSE = z
|
|
26
|
+
.boolean()
|
|
27
|
+
.default(false)
|
|
28
|
+
.describe('Include the bulky element rect and longer attribute values');
|
|
29
|
+
|
|
30
|
+
export const EXACT = z
|
|
31
|
+
.boolean()
|
|
32
|
+
.default(false)
|
|
33
|
+
.describe('For a "text=" selector, require the whole visible text to match rather than a substring');
|
|
34
|
+
|
|
35
|
+
export const NTH = z
|
|
36
|
+
.number()
|
|
37
|
+
.default(0)
|
|
38
|
+
.describe('Which match to act on when several exist, 0-based');
|
|
39
|
+
|
|
40
|
+
/** Every wait shares one ceiling, advertised so a client can see it up front. */
|
|
41
|
+
export const timeoutMs = (defaultMs, note) =>
|
|
42
|
+
z
|
|
43
|
+
.number()
|
|
44
|
+
.max(MAX_CALLER_TIMEOUT_MS)
|
|
45
|
+
.default(defaultMs)
|
|
46
|
+
.describe(
|
|
47
|
+
`${note} Capped at ${MAX_CALLER_TIMEOUT_MS}ms so the call cannot outlive a client's request timeout. To wait longer, call this again.`
|
|
48
|
+
);
|