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,217 @@
|
|
|
1
|
+
import { z, TAB_ID } from './schema.js';
|
|
2
|
+
import { write, runPage } from './shared.js';
|
|
3
|
+
import { ArcError } from '../jxa.js';
|
|
4
|
+
|
|
5
|
+
// Cumulative budget for everything batch reports back. Individual read tools cap
|
|
6
|
+
// themselves at 20000 characters each, so five of them already overshoot what a
|
|
7
|
+
// client will accept, and a clipped response is unreadable rather than short.
|
|
8
|
+
const MAX_BATCH_CHARS = 60000;
|
|
9
|
+
|
|
10
|
+
export const tools = [
|
|
11
|
+
{
|
|
12
|
+
name: 'execute_javascript',
|
|
13
|
+
description: 'Run JavaScript in a tab and return the result. A bare expression, or a statement body that uses return, both work. The helper library is available as A (A.all, A.one, A.click, A.setValue, A.describe, A.visible).',
|
|
14
|
+
input: z.object({
|
|
15
|
+
code: z.string().describe('JavaScript to evaluate. An expression returns its value; a statement body returns whatever it returns, or null.'),
|
|
16
|
+
tab_id: TAB_ID.optional()
|
|
17
|
+
}),
|
|
18
|
+
annotations: write('Execute JavaScript', { destructive: true })
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'batch',
|
|
22
|
+
description:
|
|
23
|
+
'Run several tools in order in one call, passing the same tab through. Stops at the first failure unless continue_on_error is set. Use this to cut round trips: fill, fill, click, wait. ' +
|
|
24
|
+
`Results are capped at ${MAX_BATCH_CHARS} characters across all steps: past that the batch stops early and reports truncated, so pass max_chars to reading steps or split a read-heavy sequence across calls.`,
|
|
25
|
+
input: z.object({
|
|
26
|
+
steps: z
|
|
27
|
+
.array(
|
|
28
|
+
z.object({
|
|
29
|
+
tool: z.string().describe('Name of any other tool in this server'),
|
|
30
|
+
// A bag destined for another tool, so it has to survive validation
|
|
31
|
+
// whole: z.record keeps every key, where a z.object would strip the
|
|
32
|
+
// ones it does not know. The peer's own schema checks it later,
|
|
33
|
+
// because batch calls peers through the wrapped registry handlers.
|
|
34
|
+
args: z.record(z.string(), z.unknown()).optional().describe('Arguments for that tool')
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
.describe('Steps to run in order'),
|
|
38
|
+
tab_id: TAB_ID.optional().describe('Applied to every step that does not set its own'),
|
|
39
|
+
continue_on_error: z.boolean().default(false).describe('Keep going after a failing step')
|
|
40
|
+
}),
|
|
41
|
+
annotations: write('Batch', { destructive: true })
|
|
42
|
+
}
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// `batch` needs to call peers, so the registry injects the full handler map.
|
|
46
|
+
let lookup = () => ({});
|
|
47
|
+
export function bindRegistry(fn) {
|
|
48
|
+
lookup = fn;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Parse-check a function body in Node. Returns the SyntaxError message, or null. */
|
|
52
|
+
function parseError(body) {
|
|
53
|
+
try {
|
|
54
|
+
// Built, never called: constructing it is the parse check.
|
|
55
|
+
new Function('A', body);
|
|
56
|
+
return null;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return error.message;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decide here, in Node, whether the caller's code is an expression or a
|
|
64
|
+
* statement body. Two reasons it cannot be decided in the page: a syntax error
|
|
65
|
+
* in the injected script fails at parse time, so no try/catch in the page can
|
|
66
|
+
* report it and Arc just returns empty, and a page with a strict CSP (GitHub,
|
|
67
|
+
* Google) can block the eval such a check would need.
|
|
68
|
+
* Newlines around the code keep a trailing line comment from eating the `);`.
|
|
69
|
+
*/
|
|
70
|
+
export function wrapUserCode(code) {
|
|
71
|
+
const expression = `return (\n${code}\n);`;
|
|
72
|
+
if (!parseError(expression)) return { form: 'expression', body: expression };
|
|
73
|
+
|
|
74
|
+
const statementError = parseError(code);
|
|
75
|
+
if (!statementError) return { form: 'statement', body: code };
|
|
76
|
+
|
|
77
|
+
throw new ArcError(
|
|
78
|
+
`That code does not parse: ${statementError}. It is not a valid expression either, ` +
|
|
79
|
+
'so nothing was sent to Arc.'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Two step results describe the same tab when the id and the url match. Title
|
|
84
|
+
// can flap on a single-page app without the step having changed tabs.
|
|
85
|
+
const sameTab = (a, b) => !!a && !!b && a.id === b.id && a.url === b.url;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The tab state most steps saw, which is the one worth hoisting out of the
|
|
89
|
+
* results. `>=` walks forward, so a tie picks the later state: after a
|
|
90
|
+
* navigation the caller cares about where the batch ended up.
|
|
91
|
+
*/
|
|
92
|
+
function commonTab(tabs) {
|
|
93
|
+
let best = null;
|
|
94
|
+
let bestCount = 0;
|
|
95
|
+
for (const tab of tabs) {
|
|
96
|
+
const count = tabs.filter((other) => sameTab(other, tab)).length;
|
|
97
|
+
if (count >= bestCount) {
|
|
98
|
+
best = tab;
|
|
99
|
+
bestCount = count;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Charged against the batch budget. JSON.stringify is what index.js serialises
|
|
106
|
+
// the response with, so it is the right ruler for what the caller will receive.
|
|
107
|
+
const resultChars = (entry) => JSON.stringify(entry ?? null).length;
|
|
108
|
+
|
|
109
|
+
function withoutTab(value) {
|
|
110
|
+
if (!value || typeof value !== 'object' || !('tab' in value)) return value;
|
|
111
|
+
const { tab, ...rest } = value;
|
|
112
|
+
return rest;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const handlers = {
|
|
116
|
+
execute_javascript: async (args) => {
|
|
117
|
+
const { form, body } = wrapUserCode(args.code);
|
|
118
|
+
const { result, note, tab } = await runPage(args, body);
|
|
119
|
+
// A statement body yields a value only through `return`, so an empty result
|
|
120
|
+
// there is worth explaining rather than reporting as a bare null.
|
|
121
|
+
const needsReturn = result === undefined && form === 'statement' && !/\breturn\b/.test(args.code);
|
|
122
|
+
const hint = needsReturn
|
|
123
|
+
? 'Ran as a statement body, which produces a value only through `return`. Add one to get a result back.'
|
|
124
|
+
: null;
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
// Says which wrapper was chosen, so a null result is never a mystery.
|
|
128
|
+
form,
|
|
129
|
+
result: result === undefined ? null : result,
|
|
130
|
+
...(note || hint ? { note: note || hint } : {}),
|
|
131
|
+
tab
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
batch: async (args, extra) => {
|
|
136
|
+
const steps = args.steps || [];
|
|
137
|
+
const all = lookup();
|
|
138
|
+
const ran = [];
|
|
139
|
+
let budgetLeft = MAX_BATCH_CHARS;
|
|
140
|
+
let truncated = false;
|
|
141
|
+
let note = null;
|
|
142
|
+
|
|
143
|
+
// bindRegistry runs when registry.js is imported. Importing this module on
|
|
144
|
+
// its own leaves batch with no peers, and "Unknown tool: click" is a
|
|
145
|
+
// baffling way to find that out.
|
|
146
|
+
if (Object.keys(all).length === 0) {
|
|
147
|
+
throw new ArcError(
|
|
148
|
+
'batch has no tools to call because the registry was never bound. Import ' +
|
|
149
|
+
'src/registry.js, as src/index.js does, rather than this module on its own.'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const [index, step] of steps.entries()) {
|
|
154
|
+
// A cancelled caller is not going to read the rest, and every remaining
|
|
155
|
+
// step would spawn another osascript process on the user's machine.
|
|
156
|
+
if (extra?.signal?.aborted) {
|
|
157
|
+
note = `Cancelled after ${ran.length} of ${steps.length} steps. Whatever the steps already run did to the page stands.`;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const handler = all[step.tool];
|
|
161
|
+
if (!handler) {
|
|
162
|
+
ran.push({ index, tool: step.tool, ok: false, error: `Unknown tool: ${step.tool}` });
|
|
163
|
+
if (!args.continue_on_error) break;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const stepArgs = { ...(args.tab_id ? { tab_id: args.tab_id } : {}), ...(step.args || {}) };
|
|
167
|
+
try {
|
|
168
|
+
// extra carries the client's AbortSignal, so a cancelled batch stops
|
|
169
|
+
// inside the step it is on rather than only between steps.
|
|
170
|
+
const value = await handler(stepArgs, extra);
|
|
171
|
+
const failed = value && value.ok === false;
|
|
172
|
+
ran.push({ index, tool: step.tool, ok: !failed, value });
|
|
173
|
+
if (failed && !args.continue_on_error) break;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
ran.push({ index, tool: step.tool, ok: false, error: error.message });
|
|
176
|
+
if (!args.continue_on_error) break;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Charged after the step, so the step that used up the budget still
|
|
180
|
+
// reports its result: it already ran, and dropping the value would hide
|
|
181
|
+
// a side effect the caller has to know about.
|
|
182
|
+
budgetLeft -= resultChars(ran[ran.length - 1]);
|
|
183
|
+
if (budgetLeft <= 0 && index < steps.length - 1) {
|
|
184
|
+
truncated = true;
|
|
185
|
+
note =
|
|
186
|
+
`Stopped after ${ran.length} of ${steps.length} steps: the results reached this batch's ` +
|
|
187
|
+
`${MAX_BATCH_CHARS} character budget, and a longer response would be clipped by the client ` +
|
|
188
|
+
'mid-JSON. Run the remaining steps as a second batch, and pass max_chars to the reading ' +
|
|
189
|
+
'steps (get_page_content, get_html) or a smaller limit to query_elements and get_links.';
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The tab is usually the same for every step, and repeating it (url and
|
|
195
|
+
// all) once per step buried the actual results. Report it once and flag
|
|
196
|
+
// only the steps whose tab really differs.
|
|
197
|
+
const batchTab = commonTab(ran.map((r) => r.value && r.value.tab).filter(Boolean));
|
|
198
|
+
|
|
199
|
+
const results = ran.map(({ index, tool, ok, value, error }) => {
|
|
200
|
+
if (error !== undefined) return { index, tool, ok, error };
|
|
201
|
+
const stepTab = value && value.tab;
|
|
202
|
+
const out = { index, tool, ok, result: withoutTab(value) };
|
|
203
|
+
if (stepTab && !sameTab(stepTab, batchTab)) out.tab = stepTab;
|
|
204
|
+
return out;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
ran: results.length,
|
|
209
|
+
total: steps.length,
|
|
210
|
+
ok: results.every((r) => r.ok),
|
|
211
|
+
...(truncated ? { truncated: true } : {}),
|
|
212
|
+
...(note ? { note } : {}),
|
|
213
|
+
...(batchTab ? { tab: batchTab } : {}),
|
|
214
|
+
results
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { runJxa, ArcError } from '../jxa.js';
|
|
2
|
+
import * as state from '../state.js';
|
|
3
|
+
import { pageScript } from '../page-lib.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MCP tool annotations. The spec's defaults are counterintuitive:
|
|
7
|
+
* destructiveHint and openWorldHint both default to true, and destructiveHint
|
|
8
|
+
* and idempotentHint are only meaningful when readOnlyHint is false. Every
|
|
9
|
+
* hint is therefore stated rather than left to a client's inference.
|
|
10
|
+
*
|
|
11
|
+
* openWorld defaults to true because almost every tool here touches an
|
|
12
|
+
* arbitrary web page, and untrusted external content is precisely the open
|
|
13
|
+
* world the flag exists to describe. Pass openWorld: false only for tools that
|
|
14
|
+
* read Arc's own tab and space bookkeeping.
|
|
15
|
+
*/
|
|
16
|
+
export const read = (title, options = {}) => {
|
|
17
|
+
// Same guard as write(): a stale positional argument should crash at import
|
|
18
|
+
// rather than quietly resolve to a default nobody intended.
|
|
19
|
+
if (typeof options !== 'object' || options === null) {
|
|
20
|
+
throw new Error(`read("${title}") takes an options object. Pass { openWorld: false } instead of a boolean.`);
|
|
21
|
+
}
|
|
22
|
+
const { openWorld = true } = options;
|
|
23
|
+
return {
|
|
24
|
+
title,
|
|
25
|
+
readOnlyHint: true,
|
|
26
|
+
destructiveHint: false,
|
|
27
|
+
idempotentHint: true,
|
|
28
|
+
openWorldHint: openWorld
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const write = (title, options = {}) => {
|
|
33
|
+
// This used to take a positional boolean. Mislabelling a destructive tool as
|
|
34
|
+
// safe is the worst outcome here, so a stale call site must crash at import
|
|
35
|
+
// rather than quietly resolve to destructive: false.
|
|
36
|
+
if (typeof options !== 'object' || options === null) {
|
|
37
|
+
throw new Error(`write("${title}") takes an options object. Pass { destructive: true } instead of a boolean.`);
|
|
38
|
+
}
|
|
39
|
+
const { destructive = false, idempotent = false, openWorld = true } = options;
|
|
40
|
+
return {
|
|
41
|
+
title,
|
|
42
|
+
readOnlyHint: false,
|
|
43
|
+
destructiveHint: destructive,
|
|
44
|
+
idempotentHint: idempotent,
|
|
45
|
+
openWorldHint: openWorld
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Adds the agent's implicit target and ownership info to every script. */
|
|
50
|
+
export function scoped(args = {}) {
|
|
51
|
+
// The registry decides per tool whether falling back to the user's active
|
|
52
|
+
// tab is acceptable, and passes it down out of band rather than as a
|
|
53
|
+
// caller-settable argument.
|
|
54
|
+
const { __allowActiveTab, ...rest } = args;
|
|
55
|
+
return {
|
|
56
|
+
...rest,
|
|
57
|
+
allow_active_tab: __allowActiveTab === true,
|
|
58
|
+
default_tab_id: state.currentTabId(),
|
|
59
|
+
agent_space: state.AGENT_SPACE,
|
|
60
|
+
owned_ids: state.ownedIds()
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Turn a page envelope into a plain value, or raise the page's own error.
|
|
66
|
+
* Without this a thrown page script arrives as `null` and every handler
|
|
67
|
+
* spreads it into a cheerful `{ ok: true }`.
|
|
68
|
+
*/
|
|
69
|
+
export function unwrapPage(out) {
|
|
70
|
+
const { result, tab } = out || {};
|
|
71
|
+
if (!result || result.__arc !== 1) {
|
|
72
|
+
throw new ArcError(
|
|
73
|
+
'The page script returned nothing recognisable. Arc may be blocking JavaScript from Apple ' +
|
|
74
|
+
'Events (Arc > Settings > Advanced > "Allow JavaScript from Apple Events"), or the tab ' +
|
|
75
|
+
'navigated while the call was in flight.'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (result.ok === false) {
|
|
79
|
+
throw new ArcError(`The page script failed: ${result.name}: ${result.error}`);
|
|
80
|
+
}
|
|
81
|
+
return { result: result.v, note: result.note, tab };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolve a tab, run DOM code in it, and return the value plus tab info. */
|
|
85
|
+
export function runPage(args, body, timeoutMs) {
|
|
86
|
+
return runJxa(
|
|
87
|
+
`const tab = target();
|
|
88
|
+
JSON.stringify({ result: evalJs(tab, P.page_code), tab: describe(tab) });`,
|
|
89
|
+
scoped({ ...args, page_code: pageScript(body) }),
|
|
90
|
+
timeoutMs
|
|
91
|
+
).then(unwrapPage);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Same, but for scripts whose value is the whole response. */
|
|
95
|
+
export function runTab(args, jxaBody, timeoutMs) {
|
|
96
|
+
return runJxa(jxaBody, scoped(args), timeoutMs);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
100
|
+
export { state };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from './schema.js';
|
|
2
|
+
import { read, write, runTab } from './shared.js';
|
|
3
|
+
|
|
4
|
+
export const tools = [
|
|
5
|
+
{
|
|
6
|
+
name: 'list_spaces',
|
|
7
|
+
description: 'List Arc spaces in the front window, with tab counts and which is active. Tabs pinned to the top of the sidebar (location topApp) belong to no space, so the reported tabsInSpaces plus topAppCount is what reconciles with totalTabs. Counts cover the front window only, while list_tabs covers every window.',
|
|
8
|
+
// strictObject, so the generated schema keeps additionalProperties: false.
|
|
9
|
+
input: z.strictObject({}),
|
|
10
|
+
annotations: read('List Spaces', { openWorld: false })
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'focus_space',
|
|
14
|
+
description: "Switch the front Arc window to a space. This changes what the user sees, so it is rarely needed: tabs in an unfocused space are still fully readable and scriptable.",
|
|
15
|
+
input: z.object({ space: z.string().describe('Space id or title from list_spaces') }),
|
|
16
|
+
annotations: write('Focus Space', { idempotent: true, openWorld: false })
|
|
17
|
+
}
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const handlers = {
|
|
21
|
+
list_spaces: (args) =>
|
|
22
|
+
runTab(
|
|
23
|
+
args,
|
|
24
|
+
`requireArc();
|
|
25
|
+
const w = mainWindow();
|
|
26
|
+
const activeId = w.activeSpace.id();
|
|
27
|
+
const spaces = [];
|
|
28
|
+
let tabsInSpaces = 0;
|
|
29
|
+
for (let i = 0; i < w.spaces.length; i++) {
|
|
30
|
+
const s = w.spaces[i];
|
|
31
|
+
const tabCount = s.tabs.length;
|
|
32
|
+
tabsInSpaces += tabCount;
|
|
33
|
+
spaces.push({
|
|
34
|
+
id: s.id(),
|
|
35
|
+
title: s.title(),
|
|
36
|
+
tabCount: tabCount,
|
|
37
|
+
isActive: s.id() === activeId,
|
|
38
|
+
isAgentSpace: s.title() === P.agent_space
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// space.tabs excludes topApp favourites, so the per-space counts alone
|
|
42
|
+
// never add up to what list_tabs reports. Carry the missing number.
|
|
43
|
+
const locations = w.tabs.location();
|
|
44
|
+
let topAppCount = 0;
|
|
45
|
+
for (let k = 0; k < locations.length; k++) if (locations[k] === "topApp") topAppCount++;
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
windowId: w.id(),
|
|
48
|
+
agentSpaceName: P.agent_space,
|
|
49
|
+
totalTabs: locations.length,
|
|
50
|
+
tabsInSpaces: tabsInSpaces,
|
|
51
|
+
topAppCount: topAppCount,
|
|
52
|
+
spaces: spaces
|
|
53
|
+
});`
|
|
54
|
+
),
|
|
55
|
+
|
|
56
|
+
focus_space: (args) =>
|
|
57
|
+
runTab(
|
|
58
|
+
args,
|
|
59
|
+
`requireArc();
|
|
60
|
+
const space = findSpace(P.space);
|
|
61
|
+
if (!space) throw new Error("SPACE_NOT_FOUND:" + P.space);
|
|
62
|
+
Arc.focus(space);
|
|
63
|
+
delay(0.3);
|
|
64
|
+
JSON.stringify({ ok: true, action: "focused space", space: { id: space.id(), title: space.title() } });`
|
|
65
|
+
)
|
|
66
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { runJxa } from '../jxa.js';
|
|
2
|
+
import { z, TAB_ID } from './schema.js';
|
|
3
|
+
import { read, write, scoped, runTab, state } from './shared.js';
|
|
4
|
+
|
|
5
|
+
async function snapshotAll() {
|
|
6
|
+
const result = await runJxa(
|
|
7
|
+
`requireArc();
|
|
8
|
+
const space = agentSpace();
|
|
9
|
+
JSON.stringify({ tabs: snapshot(), agentSpace: space ? { id: space.id(), title: space.title() } : null });`,
|
|
10
|
+
scoped()
|
|
11
|
+
);
|
|
12
|
+
state.reconcile(result.tabs.map((t) => t.id));
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const tools = [
|
|
17
|
+
{
|
|
18
|
+
name: 'list_tabs',
|
|
19
|
+
description: "List Arc tabs. Rows are flagged 'mine' for tabs this agent opened and 'isActive' for the tab the user is on. Defaults to every tab; pass scope 'own' to narrow.",
|
|
20
|
+
input: z.object({
|
|
21
|
+
scope: z.enum(['all', 'own']).default('all').describe("'all' (default) or only this agent's tabs"),
|
|
22
|
+
query: z.string().describe('Case-insensitive substring matched against title and url').optional(),
|
|
23
|
+
space: z.string().describe('Only tabs in this space title').optional(),
|
|
24
|
+
window_id: z.string().describe('Restrict to one window id').optional()
|
|
25
|
+
}),
|
|
26
|
+
annotations: read('List Tabs', { openWorld: false })
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'get_current_tab',
|
|
30
|
+
description: "Get the tab a call with no tab_id would act on: this agent's current tab, or the active tab if it has none.",
|
|
31
|
+
input: z.object({ tab_id: TAB_ID.optional() }),
|
|
32
|
+
annotations: read('Get Current Tab', { openWorld: false })
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'switch_to_tab',
|
|
36
|
+
description: 'Make a tab the active tab in its window. Changes what the user sees, so prefer reading a tab by id when you only need its content.',
|
|
37
|
+
input: z.object({
|
|
38
|
+
// Mandatory here, so the fallback wording the shared schema carries would
|
|
39
|
+
// only be misleading.
|
|
40
|
+
tab_id: TAB_ID.describe('Arc tab id from list_tabs'),
|
|
41
|
+
activate: z.boolean().default(false).describe('Also bring Arc to the front')
|
|
42
|
+
}),
|
|
43
|
+
annotations: write('Switch To Tab', { idempotent: true, openWorld: false })
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'close_tab',
|
|
47
|
+
description:
|
|
48
|
+
"Close one tab. With no tab_id this closes this agent's own current tab, and is refused outright if the agent has not opened one: it will never close the tab the user is looking at. Pass an explicit tab_id from list_tabs to close any other tab. Closing cannot be undone and tab ids are not reused. To clean up after yourself, prefer close_own_tabs.",
|
|
49
|
+
input: z.object({ tab_id: TAB_ID.optional() }),
|
|
50
|
+
annotations: write('Close Tab', { destructive: true, openWorld: false })
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'close_own_tabs',
|
|
54
|
+
description: "Close every tab this agent opened, leaving the user's tabs alone. Tabs leaked by a previous run of this label are left alone too unless include_stale is set.",
|
|
55
|
+
input: z.object({
|
|
56
|
+
include_stale: z
|
|
57
|
+
.boolean()
|
|
58
|
+
.default(false)
|
|
59
|
+
.describe(
|
|
60
|
+
"Also close tabs left behind by dead sessions of this label (see staleTabCount in arc_status). Never touches a live agent's tabs."
|
|
61
|
+
)
|
|
62
|
+
}),
|
|
63
|
+
annotations: write('Close Own Tabs', { destructive: true, openWorld: false })
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'arc_status',
|
|
67
|
+
description: 'Report Arc state: which tabs this agent owns, whether the agent space exists, what a call with no tab_id resolves to, and how many tabs a previous run of this label left behind.',
|
|
68
|
+
// strictObject, so the generated schema keeps additionalProperties: false.
|
|
69
|
+
input: z.strictObject({}),
|
|
70
|
+
annotations: read('Arc Status', { openWorld: false })
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export const handlers = {
|
|
75
|
+
arc_status: async () => {
|
|
76
|
+
const { tabs, agentSpace } = await snapshotAll();
|
|
77
|
+
const owned = new Set(state.ownedIds());
|
|
78
|
+
const active = tabs.find((t) => t.isActive);
|
|
79
|
+
// Mirrors target() in jxa.js: the agent's own tab only counts while it is
|
|
80
|
+
// still open, and a dead one falls through the same way as never having had
|
|
81
|
+
// one at all.
|
|
82
|
+
const currentTabId = state.currentTabId();
|
|
83
|
+
const ownTab = (currentTabId && tabs.find((t) => t.id === currentTabId)) || null;
|
|
84
|
+
return {
|
|
85
|
+
label: state.label(),
|
|
86
|
+
// Ownership is per session, so two agents can share a label without
|
|
87
|
+
// seeing each other's tabs. The file is where a restart looks for leaks.
|
|
88
|
+
sessionId: state.sessionId(),
|
|
89
|
+
stateFile: state.stateFile(),
|
|
90
|
+
isolation: agentSpace ? 'space' : 'shared-window',
|
|
91
|
+
agentSpace,
|
|
92
|
+
agentSpaceName: state.AGENT_SPACE,
|
|
93
|
+
// A read-only tool and one that changes a tab no longer resolve the same
|
|
94
|
+
// way once this agent owns no tab, so each gets its own answer rather
|
|
95
|
+
// than one reason that is only half true.
|
|
96
|
+
resolvesTo: ownTab
|
|
97
|
+
? {
|
|
98
|
+
readOnly: { reason: "this agent's current tab", tab: ownTab },
|
|
99
|
+
mutating: { reason: "this agent's current tab", tab: ownTab }
|
|
100
|
+
}
|
|
101
|
+
: {
|
|
102
|
+
readOnly: {
|
|
103
|
+
reason: 'no agent tab, so a read-only tool falls back to the tab the user is looking at',
|
|
104
|
+
tab: active || null
|
|
105
|
+
},
|
|
106
|
+
mutating: {
|
|
107
|
+
reason:
|
|
108
|
+
"no agent tab, so a tool that changes a tab is refused rather than acting on the user's tab. Pass a tab_id from list_tabs, or call open_url first.",
|
|
109
|
+
tab: null
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
ownTabs: tabs.filter((t) => owned.has(t.id)),
|
|
113
|
+
staleTabCount: state.staleIds().length,
|
|
114
|
+
otherTabCount: tabs.filter((t) => !owned.has(t.id)).length
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
list_tabs: async (args) => {
|
|
119
|
+
const { tabs } = await snapshotAll();
|
|
120
|
+
const owned = new Set(state.ownedIds());
|
|
121
|
+
const scope = args.scope || 'all';
|
|
122
|
+
|
|
123
|
+
let rows = tabs.map((t) => ({ ...t, mine: owned.has(t.id) }));
|
|
124
|
+
if (scope === 'own') rows = rows.filter((t) => t.mine);
|
|
125
|
+
if (args.window_id) rows = rows.filter((t) => t.windowId === args.window_id);
|
|
126
|
+
if (args.space) rows = rows.filter((t) => t.space === args.space);
|
|
127
|
+
if (args.query) {
|
|
128
|
+
const q = args.query.toLowerCase();
|
|
129
|
+
rows = rows.filter((t) => (t.title || '').toLowerCase().includes(q) || (t.url || '').toLowerCase().includes(q));
|
|
130
|
+
}
|
|
131
|
+
return { scope, count: rows.length, tabs: rows };
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
get_current_tab: (args) => runTab(args, `const tab = target(); JSON.stringify(describe(tab));`),
|
|
135
|
+
|
|
136
|
+
switch_to_tab: async (args) => {
|
|
137
|
+
const result = await runTab(
|
|
138
|
+
args,
|
|
139
|
+
`const tab = target();
|
|
140
|
+
Arc.select(tab);
|
|
141
|
+
if (P.activate) Arc.activate();
|
|
142
|
+
delay(0.3);
|
|
143
|
+
JSON.stringify({ ok: true, action: "switched", tab: describe(tab) });`
|
|
144
|
+
);
|
|
145
|
+
state.focusOwn(result.tab.id);
|
|
146
|
+
return result;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
close_tab: async (args) => {
|
|
150
|
+
const result = await runTab(
|
|
151
|
+
args,
|
|
152
|
+
`const tab = target();
|
|
153
|
+
const info = describe(tab);
|
|
154
|
+
Arc.close(tab);
|
|
155
|
+
JSON.stringify({ ok: true, action: "closed", tab: info });`
|
|
156
|
+
);
|
|
157
|
+
state.release(result.tab.id);
|
|
158
|
+
return result;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
close_own_tabs: async (args = {}) => {
|
|
162
|
+
// Stale tabs are opt-in: reaping them by default would make every restart
|
|
163
|
+
// destructive, and a recycled pid could make a live sibling look dead.
|
|
164
|
+
const stale = args.include_stale ? state.staleIds() : [];
|
|
165
|
+
const ids = [...new Set([...state.ownedIds(), ...stale])];
|
|
166
|
+
if (ids.length === 0) return { ok: true, closed: 0, note: 'This agent had no tabs open.' };
|
|
167
|
+
const result = await runJxa(
|
|
168
|
+
`requireArc();
|
|
169
|
+
const closed = [];
|
|
170
|
+
for (let i = 0; i < P.ids.length; i++) {
|
|
171
|
+
const tab = locate(P.ids[i]);
|
|
172
|
+
if (!tab) continue;
|
|
173
|
+
closed.push({ id: P.ids[i], title: tab.title() });
|
|
174
|
+
Arc.close(tab);
|
|
175
|
+
delay(0.2);
|
|
176
|
+
}
|
|
177
|
+
JSON.stringify({ closed: closed });`,
|
|
178
|
+
{ ids }
|
|
179
|
+
);
|
|
180
|
+
result.closed.forEach((t) => state.release(t.id));
|
|
181
|
+
const staleClosed = result.closed.filter((t) => stale.includes(t.id)).length;
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
closed: result.closed.length,
|
|
185
|
+
...(staleClosed > 0 ? { staleClosed } : {}),
|
|
186
|
+
tabs: result.closed
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
};
|