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/src/state.js ADDED
@@ -0,0 +1,216 @@
1
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, openSync, closeSync, rmSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ // Ownership is per process, never seeded from disk: two agents can share a
6
+ // label, and adopting the other one's tabs would let close_own_tabs close
7
+ // them. The file is keyed by session so a restarted agent can still find the
8
+ // tabs its dead predecessor leaked, and reap them on purpose (staleIds).
9
+ const STATE_DIR = process.env.ARC_MCP_STATE_DIR || join(homedir(), 'Library', 'Application Support', 'arc-control-mcp');
10
+ const LABEL = process.env.ARC_MCP_LABEL || 'default';
11
+ const STATE_FILE = join(STATE_DIR, `${LABEL}.json`);
12
+ const LOCK_FILE = `${STATE_FILE}.lock`;
13
+ const LOCK_WAIT_MS = 2000;
14
+ const LOCK_RETRY_MS = 5;
15
+
16
+ // pids get reused, so the start time keeps a restarted agent from being
17
+ // mistaken for its own previous session.
18
+ const SESSION_ID = `${process.pid}-${Math.round(Date.now() - process.uptime() * 1000)}`;
19
+
20
+ // How long a dead session's tab ids stay interesting. Long enough to survive a
21
+ // weekend of restarts, short enough that the file cannot grow forever.
22
+ const STALE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
23
+
24
+ export const AGENT_SPACE = process.env.ARC_MCP_SPACE || 'Agent';
25
+
26
+ const session = { owned: new Set(), currentTabId: null };
27
+
28
+ const pidOf = (sessionId) => Number.parseInt(String(sessionId).split('-')[0], 10);
29
+
30
+ // EPERM means the pid exists but belongs to someone else, which still counts
31
+ // as alive. Anything else (ESRCH, a malformed id) means gone.
32
+ function isAlive(pid) {
33
+ if (!Number.isInteger(pid) || pid <= 0) return false;
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ } catch (error) {
38
+ return error.code === 'EPERM';
39
+ }
40
+ }
41
+
42
+ const isDeadSession = (sessionId) => sessionId !== SESSION_ID && !isAlive(pidOf(sessionId));
43
+
44
+ function readSessions() {
45
+ let raw;
46
+ try {
47
+ raw = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
48
+ } catch {
49
+ return {};
50
+ }
51
+ if (raw && raw.sessions && typeof raw.sessions === 'object') return raw.sessions;
52
+ // A file written by the flat pre-session format carries no session identity,
53
+ // so there is no way to tell whether its tabs were left by a run that ended
54
+ // or are being used right now by a server still on the old code. Treating
55
+ // them as reapable once marked two of the user's live tabs for closing, so
56
+ // they are dropped instead. The cost is that genuinely leaked pre-upgrade
57
+ // tabs need closing by hand, which is far cheaper than closing live ones.
58
+ if (raw && Array.isArray(raw.owned) && raw.owned.length > 0) {
59
+ console.error(
60
+ `arc-control: ignoring ${raw.owned.length} tab id(s) from the pre-session state format, ` +
61
+ 'since they cannot be attributed to a finished run. Close them by hand if they were left behind.'
62
+ );
63
+ }
64
+ return {};
65
+ }
66
+
67
+ /** Dead sessions are kept only while they still name tabs worth reaping. */
68
+ function compact(sessions) {
69
+ const cutoff = Date.now() - STALE_RETENTION_MS;
70
+ const out = {};
71
+ for (const [id, entry] of Object.entries(sessions)) {
72
+ if (!isDeadSession(id)) {
73
+ out[id] = entry;
74
+ continue;
75
+ }
76
+ if (!entry.owned || entry.owned.length === 0) continue;
77
+ const updatedAt = Date.parse(entry.updatedAt || '');
78
+ if (Number.isFinite(updatedAt) && updatedAt < cutoff) continue;
79
+ out[id] = entry;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function writeAtomic(sessions) {
85
+ // Temp name carries the session id so two writers never share one, and the
86
+ // rename means a crash mid-write cannot leave a half-written state file.
87
+ const tmp = `${STATE_FILE}.${SESSION_ID}.tmp`;
88
+ writeFileSync(tmp, JSON.stringify({ label: LABEL, sessions }, null, 2));
89
+ renameSync(tmp, STATE_FILE);
90
+ }
91
+
92
+ // The state file is tiny, so a blocking wait costs microseconds and is much
93
+ // simpler than an async lock in code paths that are otherwise synchronous.
94
+ const sleepSync = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
95
+
96
+ /**
97
+ * Cross-process mutex. Without it, two agents reading and writing the same
98
+ * file lose each other's updates: measured, one agent's tab vanished from disk
99
+ * and stopped being reapable. `wx` fails when the lock exists, which is the
100
+ * whole mechanism. A lock older than LOCK_WAIT_MS belonged to a crashed
101
+ * process, so it is broken rather than left to wedge every later call.
102
+ */
103
+ function withLock(fn) {
104
+ const deadline = Date.now() + LOCK_WAIT_MS;
105
+ let fd;
106
+ for (;;) {
107
+ try {
108
+ fd = openSync(LOCK_FILE, 'wx');
109
+ break;
110
+ } catch (error) {
111
+ if (error.code !== 'EEXIST') throw error;
112
+ if (Date.now() > deadline) {
113
+ rmSync(LOCK_FILE, { force: true });
114
+ fd = openSync(LOCK_FILE, 'w');
115
+ break;
116
+ }
117
+ sleepSync(LOCK_RETRY_MS);
118
+ }
119
+ }
120
+ try {
121
+ return fn();
122
+ } finally {
123
+ closeSync(fd);
124
+ rmSync(LOCK_FILE, { force: true });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Read-modify-write, so a sibling process sharing this label keeps its own
130
+ * entry. `reaped` ids are also dropped from the dead sessions that listed
131
+ * them, which is how a closed leaked tab stops being reported as stale.
132
+ * A persistence failure is logged and swallowed: it must never fail a tool call.
133
+ */
134
+ function persist(reaped) {
135
+ try {
136
+ mkdirSync(STATE_DIR, { recursive: true });
137
+ withLock(() => {
138
+ const sessions = readSessions();
139
+ sessions[SESSION_ID] = {
140
+ owned: [...session.owned],
141
+ currentTabId: session.currentTabId,
142
+ updatedAt: new Date().toISOString()
143
+ };
144
+ if (reaped && reaped.size > 0) {
145
+ for (const [id, entry] of Object.entries(sessions)) {
146
+ if (id === SESSION_ID) continue;
147
+ sessions[id] = { ...entry, owned: (entry.owned || []).filter((tabId) => !reaped.has(tabId)) };
148
+ }
149
+ }
150
+ writeAtomic(compact(sessions));
151
+ });
152
+ } catch (error) {
153
+ console.error('arc-control: could not persist state:', error.message);
154
+ }
155
+ }
156
+
157
+ export function claim(tabId) {
158
+ if (!tabId) return;
159
+ session.owned.add(tabId);
160
+ session.currentTabId = tabId;
161
+ persist();
162
+ }
163
+
164
+ export function release(tabId) {
165
+ session.owned.delete(tabId);
166
+ if (session.currentTabId === tabId) {
167
+ session.currentTabId = [...session.owned].pop() || null;
168
+ }
169
+ persist(new Set([tabId]));
170
+ }
171
+
172
+ export function focusOwn(tabId) {
173
+ if (!session.owned.has(tabId)) return;
174
+ session.currentTabId = tabId;
175
+ persist();
176
+ }
177
+
178
+ export const ownedIds = () => [...session.owned];
179
+ export const currentTabId = () => session.currentTabId;
180
+ export const isOwned = (tabId) => session.owned.has(tabId);
181
+ export const label = () => LABEL;
182
+ export const sessionId = () => SESSION_ID;
183
+ export const stateFile = () => STATE_FILE;
184
+
185
+ /**
186
+ * Tabs left behind by dead sessions of this label. Cleanup can reap these
187
+ * deliberately; a live sibling's tabs are never in here.
188
+ */
189
+ export function staleIds() {
190
+ const out = new Set();
191
+ for (const [id, entry] of Object.entries(readSessions())) {
192
+ if (!isDeadSession(id)) continue;
193
+ for (const tabId of entry.owned || []) {
194
+ if (!session.owned.has(tabId)) out.add(tabId);
195
+ }
196
+ }
197
+ return [...out];
198
+ }
199
+
200
+ /** Drop ids for tabs that no longer exist, so state does not grow forever. */
201
+ export function reconcile(liveIds) {
202
+ const live = new Set(liveIds);
203
+ let changed = false;
204
+ for (const id of session.owned) {
205
+ if (!live.has(id)) {
206
+ session.owned.delete(id);
207
+ changed = true;
208
+ }
209
+ }
210
+ if (session.currentTabId && !live.has(session.currentTabId)) {
211
+ session.currentTabId = [...session.owned].pop() || null;
212
+ changed = true;
213
+ }
214
+ const vanished = new Set(staleIds().filter((id) => !live.has(id)));
215
+ if (changed || vanished.size > 0) persist(vanished);
216
+ }
@@ -0,0 +1,217 @@
1
+ import { z, TAB_ID, SELECTOR, VERBOSE } from './schema.js';
2
+ import { read, runPage } from './shared.js';
3
+
4
+ const DEFAULT_MAX_CHARS = 20000;
5
+ const DEFAULT_ELEMENT_LIMIT = 40;
6
+ const DEFAULT_LINK_LIMIT = 100;
7
+
8
+ // A blank line between joined element texts, so paragraph boundaries survive.
9
+ const PARAGRAPH_GAP = '\n\n';
10
+ // Cannot occur in an href or in visible text, so it is a safe key joiner.
11
+ const DEDUPE_SEP = '\u0000';
12
+
13
+ export const tools = [
14
+ {
15
+ name: 'get_page_content',
16
+ description:
17
+ 'Get the visible text of a page, or of every element matching a selector joined with blank lines. Always reports "matched", so a partial answer is never silent. Nested matches repeat their text, so prefer a leaf-ish selector.',
18
+ input: z.object({
19
+ tab_id: TAB_ID.optional(),
20
+ selector: SELECTOR.optional(),
21
+ first_only: z.boolean().default(false).describe('Return only the first match instead of joining all of them'),
22
+ max_chars: z.number().default(DEFAULT_MAX_CHARS).describe('Truncate the joined text at this length')
23
+ }),
24
+ annotations: read('Get Page Content')
25
+ },
26
+ {
27
+ name: 'get_html',
28
+ description:
29
+ 'Get the HTML of a page or element. Use when you need markup, attributes or structure rather than text. Returns one element only: it reports how many matched and takes nth to pick a different one.',
30
+ input: z.object({
31
+ tab_id: TAB_ID.optional(),
32
+ selector: SELECTOR.optional(),
33
+ nth: z.number().default(0).describe('Which match to return when several exist, 0-based'),
34
+ outer: z.boolean().default(true).describe('Include the element tag itself'),
35
+ max_chars: z.number().default(DEFAULT_MAX_CHARS).describe('Truncate at this length')
36
+ }),
37
+ annotations: read('Get HTML')
38
+ },
39
+ {
40
+ name: 'query_elements',
41
+ description:
42
+ 'Find elements and return structured details: text, value, href, visibility, disabled state and attributes. The main way to see what is on a page before acting on it. "text=" matching is substring, with exact matches ranked first; pass exact to require an exact match.',
43
+ input: z.object({
44
+ tab_id: TAB_ID.optional(),
45
+ selector: SELECTOR,
46
+ limit: z.number().default(DEFAULT_ELEMENT_LIMIT).describe('Maximum elements to return'),
47
+ visible_only: z.boolean().default(false).describe('Skip hidden elements'),
48
+ exact: z.boolean().default(false).describe('For a "text=" selector, match the whole text rather than a substring'),
49
+ verbose: VERBOSE
50
+ }),
51
+ annotations: read('Query Elements')
52
+ },
53
+ {
54
+ name: 'get_links',
55
+ description:
56
+ 'List links on the page with their text and resolved href. Pass unique to collapse repeated href and text pairs, which navigation and footers produce in bulk.',
57
+ input: z.object({
58
+ tab_id: TAB_ID.optional(),
59
+ query: z.string().optional().describe('Case-insensitive substring matched against link text and href'),
60
+ unique: z.boolean().default(false).describe('Collapse links with an identical href and text, reporting how many were dropped'),
61
+ limit: z.number().default(DEFAULT_LINK_LIMIT).describe('Maximum links to return')
62
+ }),
63
+ annotations: read('Get Links')
64
+ },
65
+ {
66
+ name: 'get_page_info',
67
+ description: 'Page overview: title, url, ready state, meta description, headings, form and frame counts. Cheap orientation before deciding what to do.',
68
+ input: z.object({ tab_id: TAB_ID.optional() }),
69
+ annotations: read('Get Page Info')
70
+ }
71
+ ];
72
+
73
+ export const handlers = {
74
+ get_page_content: async (args) => {
75
+ const limit = args.max_chars ?? DEFAULT_MAX_CHARS;
76
+ const firstOnly = args.first_only === true;
77
+ const { result, tab } = await runPage(
78
+ args,
79
+ `var sel = ${JSON.stringify(args.selector || null)};
80
+ var limit = ${limit};
81
+ // documentElement covers a document whose body has not parsed yet.
82
+ var els = sel ? A.all(sel) : [document.body || document.documentElement];
83
+ if (!els.length) return A.miss(sel);
84
+ var take = ${firstOnly} ? 1 : els.length;
85
+ var parts = [];
86
+ for (var i = 0; i < els.length && parts.length < take; i++) parts.push(els[i].innerText || '');
87
+ var text = parts.join(${JSON.stringify(PARAGRAPH_GAP)});
88
+ return {
89
+ text: text.slice(0, limit),
90
+ length: text.length,
91
+ truncated: text.length > limit,
92
+ matched: els.length,
93
+ returned: parts.length
94
+ };`
95
+ );
96
+ if (result?.error === 'no_match') return { error: `No element matches ${args.selector}`, tab };
97
+ if (firstOnly && result.matched > 1) {
98
+ const note = `first_only: this is 1 of ${result.matched} matches for "${args.selector}". Omit first_only to join them all.`;
99
+ return { ...result, note, tab };
100
+ }
101
+ return { ...result, tab };
102
+ },
103
+
104
+ get_html: async (args) => {
105
+ const limit = args.max_chars ?? DEFAULT_MAX_CHARS;
106
+ const nth = args.nth ?? 0;
107
+ const { result, tab } = await runPage(
108
+ args,
109
+ `var sel = ${JSON.stringify(args.selector || null)};
110
+ var limit = ${limit};
111
+ var els = sel ? A.all(sel) : [document.documentElement];
112
+ var el = els[${nth}];
113
+ if (!el) return { error: 'no_match', matched: els.length };
114
+ var html = ${args.outer === false ? 'el.innerHTML' : 'el.outerHTML'} || '';
115
+ return {
116
+ html: html.slice(0, limit),
117
+ length: html.length,
118
+ truncated: html.length > limit,
119
+ matched: els.length,
120
+ nth: ${nth}
121
+ };`
122
+ );
123
+ if (result?.error === 'no_match') {
124
+ // "nothing matched" and "nth is past the end" look alike to the page but
125
+ // need different fixes, so name which one happened.
126
+ const error = result.matched
127
+ ? `nth ${nth} is out of range: ${result.matched} element(s) match ${args.selector}`
128
+ : `No element matches ${args.selector}`;
129
+ return { error, matched: result.matched, tab };
130
+ }
131
+ if (result.matched > 1) {
132
+ const note = `${result.matched} elements match "${args.selector}" and this is nth ${nth}. Narrow the selector, or pass nth for another. Use get_page_content for the text of all of them.`;
133
+ return { ...result, note, tab };
134
+ }
135
+ return { ...result, tab };
136
+ },
137
+
138
+ query_elements: async (args) => {
139
+ const { result, tab } = await runPage(
140
+ args,
141
+ `var els = A.all(${JSON.stringify(args.selector)}, null, { exact: ${args.exact === true} });
142
+ var out = [];
143
+ for (var i = 0; i < els.length && out.length < ${args.limit ?? DEFAULT_ELEMENT_LIMIT}; i++) {
144
+ var d = A.describe(els[i], ${args.verbose === true});
145
+ if (${args.visible_only === true} && !d.visible) continue;
146
+ d.index = i;
147
+ out.push(d);
148
+ }
149
+ return { total: els.length, returned: out.length, elements: out };`
150
+ );
151
+ return { ...result, selector: args.selector, tab };
152
+ },
153
+
154
+ get_links: async (args) => {
155
+ const { result, tab } = await runPage(
156
+ args,
157
+ `var q = ${JSON.stringify((args.query || '').toLowerCase())};
158
+ var unique = ${args.unique === true};
159
+ var limit = ${args.limit ?? DEFAULT_LINK_LIMIT};
160
+ var links = document.querySelectorAll('a[href]');
161
+ var out = [];
162
+ var seen = {};
163
+ var matched = 0;
164
+ var collapsed = 0;
165
+ // Scans every anchor even once the limit is hit, so the counts describe
166
+ // the page rather than the first slice of it.
167
+ for (var i = 0; i < links.length; i++) {
168
+ var a = links[i];
169
+ var text = (a.innerText || a.getAttribute('aria-label') || '').trim();
170
+ var href = a.href;
171
+ if (q && (text + ' ' + href).toLowerCase().indexOf(q) === -1) continue;
172
+ matched++;
173
+ if (unique) {
174
+ var key = href + ${JSON.stringify(DEDUPE_SEP)} + text;
175
+ if (seen[key]) { collapsed++; continue; }
176
+ seen[key] = 1;
177
+ }
178
+ if (out.length >= limit) continue;
179
+ out.push({ text: text.slice(0, 200), href: href, visible: A.visible(a) });
180
+ }
181
+ return { total: links.length, matched: matched, collapsed: collapsed, returned: out.length, links: out };`
182
+ );
183
+ return { ...result, tab };
184
+ },
185
+
186
+ get_page_info: async (args) => {
187
+ const { result, tab } = await runPage(
188
+ args,
189
+ `function meta(name) {
190
+ var m = document.querySelector('meta[name="' + name + '"], meta[property="og:' + name + '"]');
191
+ return m ? m.content : null;
192
+ }
193
+ var hs = [];
194
+ var nodes = document.querySelectorAll('h1,h2,h3');
195
+ for (var i = 0; i < nodes.length && hs.length < 30; i++) {
196
+ var t = (nodes[i].innerText || '').trim();
197
+ if (t) hs.push({ level: nodes[i].tagName.toLowerCase(), text: t.slice(0, 150) });
198
+ }
199
+ return {
200
+ title: document.title,
201
+ url: location.href,
202
+ ready: document.readyState,
203
+ description: meta('description'),
204
+ headings: hs,
205
+ counts: {
206
+ links: document.querySelectorAll('a[href]').length,
207
+ forms: document.forms.length,
208
+ inputs: document.querySelectorAll('input,textarea,select').length,
209
+ buttons: document.querySelectorAll('button,[role=button]').length,
210
+ iframes: document.querySelectorAll('iframe').length
211
+ },
212
+ textLength: (document.body ? document.body.innerText : '').length
213
+ };`
214
+ );
215
+ return { ...result, tab };
216
+ }
217
+ };