@aryacode/dsh-plugin-arya-computer-use 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/NOTICE.md +18 -0
- package/README.md +108 -2
- package/client.js +492 -0
- package/cordis.patch.yml +16 -0
- package/docs/PLAN.md +151 -0
- package/icon.svg +6 -0
- package/index.js +543 -0
- package/locale/en.json +6 -0
- package/mcp/activity.mjs +299 -0
- package/mcp/adapter-server.mjs +626 -0
- package/mcp/browser-launcher.mjs +66 -0
- package/mcp/settings.mjs +87 -0
- package/mcp/takeover.mjs +253 -0
- package/package.json +88 -4
- package/scripts/ocr.ps1 +95 -0
- package/scripts/windows-takeover.cs +1190 -0
- package/scripts/windows-takeover.ps1 +25 -0
- package/skills/arya-desktop/SKILL.md +75 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Arya Computer Use adapter — Lite tool surface over the agent-desktop CLI.
|
|
4
|
+
*
|
|
5
|
+
* Why a proxy: agent-desktop's own MCP server fixes global flags (--session,
|
|
6
|
+
* --headed) at server start. This adapter spawns the CLI per call so it can
|
|
7
|
+
* inject per-conversation session ids, the headed toggle, and manage the
|
|
8
|
+
* cursor overlay lifecycle. It also owns the tool surface (DSH's MCP client
|
|
9
|
+
* registers every tool a server exposes, with no filtering), adds OCR
|
|
10
|
+
* (agent-desktop has none) and screenshot-to-file, and surfaces the
|
|
11
|
+
* elevation requirement as a readable diagnostic.
|
|
12
|
+
*
|
|
13
|
+
* Tool names register as mcp__arya_desktop__<name>.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { createInterface } from 'node:readline';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const CLI_BIN = require.resolve('agent-desktop/bin/agent-desktop');
|
|
26
|
+
const OCR_PS1 = join(HERE, '..', 'scripts', 'ocr.ps1');
|
|
27
|
+
|
|
28
|
+
// Engine state root: the engine's own default (~/.agent-desktop) — it creates
|
|
29
|
+
// that root with its protected DACL and passes its private-file checks.
|
|
30
|
+
// Do NOT point AGENT_DESKTOP_HOME at AppData\Local: inherited AppContainer ACEs
|
|
31
|
+
// there fail the engine's 'private file DACL grants an untrusted principal' check.
|
|
32
|
+
const ENGINE_STATE_ROOT =
|
|
33
|
+
process.env.AGENT_DESKTOP_HOME || join(process.env.USERPROFILE || HERE, '.agent-desktop');
|
|
34
|
+
const LOCAL_APPDATA = process.env.LOCALAPPDATA || join(process.env.USERPROFILE || HERE, 'AppData', 'Local');
|
|
35
|
+
const PLUGIN_DIR = join(LOCAL_APPDATA, 'dsh-plugin-arya-desktop');
|
|
36
|
+
const SHOTS_DIR = join(PLUGIN_DIR, 'screenshots');
|
|
37
|
+
mkdirSync(SHOTS_DIR, { recursive: true });
|
|
38
|
+
|
|
39
|
+
// Behavior switches live in the host plugin's control file (settings.register
|
|
40
|
+
// 'arya-computer-use' → index.js writes it here). The adapter polls it so
|
|
41
|
+
// changes apply to the next call without restarting the MCP connection.
|
|
42
|
+
const SETTINGS_FILE = join(PLUGIN_DIR, 'control', 'settings.json');
|
|
43
|
+
// The overlay host reads this to frame the app window that is being driven and
|
|
44
|
+
// to place the ghost cursor where the harness acts — never the real pointer.
|
|
45
|
+
const TARGET_FILE = join(PLUGIN_DIR, 'control', 'target.json');
|
|
46
|
+
|
|
47
|
+
const VERSION = '0.1.0';
|
|
48
|
+
const MAX_BATCH = 20;
|
|
49
|
+
|
|
50
|
+
const state = {
|
|
51
|
+
sessionId: `arya-${randomBytes(4).toString('hex')}`,
|
|
52
|
+
sessionStarted: false,
|
|
53
|
+
headedOverride: null, // tools {operation:'headed'} per-conversation toggle
|
|
54
|
+
overlayOverride: null,
|
|
55
|
+
headedDefault: false,
|
|
56
|
+
overlayDefault: true,
|
|
57
|
+
windowId: null,
|
|
58
|
+
toolPreset: 'lite', // 'lite' = all 16 tools, 'lazy' = three to start, rest on demand
|
|
59
|
+
extraTools: new Set(), // tools revealed by tools {operation:'load'} in the lazy preset
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// The lazy preset starts with the three tools needed to look, read, and act
|
|
63
|
+
// once; everything else is revealed through `tools {operation:'load'}` and a
|
|
64
|
+
// tools/list_changed notification. The harness re-syncs on that notification
|
|
65
|
+
// (dsh-mcp-client wires listChanged.onChanged → refreshTools).
|
|
66
|
+
const LAZY_CORE = ['tools', 'snapshot', 'click'];
|
|
67
|
+
const exposedTools = () => (state.toolPreset === 'lazy'
|
|
68
|
+
? TOOLS.filter((tool) => LAZY_CORE.includes(tool.name) || state.extraTools.has(tool.name))
|
|
69
|
+
: TOOLS);
|
|
70
|
+
|
|
71
|
+
function notifyToolsChanged() {
|
|
72
|
+
// Advertised through capabilities.tools.listChanged; the client re-lists and
|
|
73
|
+
// swaps its registered tool generation.
|
|
74
|
+
send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
|
|
75
|
+
}
|
|
76
|
+
const effectiveHeaded = () => state.headedOverride ?? state.headedDefault;
|
|
77
|
+
const effectiveOverlay = () => state.overlayOverride ?? state.overlayDefault;
|
|
78
|
+
|
|
79
|
+
setInterval(() => {
|
|
80
|
+
try {
|
|
81
|
+
const settings = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
|
|
82
|
+
state.headedDefault = settings.headedDefault !== false;
|
|
83
|
+
state.overlayDefault = settings.overlayDefault !== false;
|
|
84
|
+
const preset = settings.toolPreset === 'lazy' ? 'lazy' : 'lite';
|
|
85
|
+
if (preset !== state.toolPreset) {
|
|
86
|
+
state.toolPreset = preset;
|
|
87
|
+
state.extraTools.clear();
|
|
88
|
+
notifyToolsChanged();
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Settings file not written yet (plugin not loaded): keep the defaults.
|
|
92
|
+
}
|
|
93
|
+
}, 1000).unref();
|
|
94
|
+
|
|
95
|
+
/* ------------------------------------------------------- overlay target */
|
|
96
|
+
|
|
97
|
+
// The overlay frames the app window that is being driven and puts the ghost
|
|
98
|
+
// cursor where the harness acts. Both come from what the engine reports, so the
|
|
99
|
+
// user's own pointer is never read, moved or highlighted.
|
|
100
|
+
const windowBounds = new Map(); // window id -> {left, top, width, height}
|
|
101
|
+
let windowBoundsAt = 0;
|
|
102
|
+
|
|
103
|
+
function rememberWindows(data) {
|
|
104
|
+
const list = Array.isArray(data) ? data : Array.isArray(data?.windows) ? data.windows : data?.window ? [data.window] : [];
|
|
105
|
+
for (const window of list) rememberWindow(window);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function rememberWindow(window) {
|
|
109
|
+
if (!window || !window.id || !window.bounds) return null;
|
|
110
|
+
const b = window.bounds;
|
|
111
|
+
const rect = { left: Number(b.x), top: Number(b.y), width: Number(b.width), height: Number(b.height) };
|
|
112
|
+
if (![rect.left, rect.top, rect.width, rect.height].every(Number.isFinite) || rect.width <= 0 || rect.height <= 0) return null;
|
|
113
|
+
windowBounds.set(window.id, rect);
|
|
114
|
+
return rect;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function windowRectFor(id) {
|
|
118
|
+
if (!id) return null;
|
|
119
|
+
const cached = windowBounds.get(id);
|
|
120
|
+
if (cached) return cached;
|
|
121
|
+
if (Date.now() - windowBoundsAt < 2000) return null;
|
|
122
|
+
try {
|
|
123
|
+
const envelope = parseEnvelope(await runCli(['list-windows']));
|
|
124
|
+
if (envelope?.ok) {
|
|
125
|
+
rememberWindows(envelope.data);
|
|
126
|
+
windowBoundsAt = Date.now();
|
|
127
|
+
}
|
|
128
|
+
} catch { /* the hint is optional */ }
|
|
129
|
+
return windowBounds.get(id) ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function elementPoint(ref) {
|
|
133
|
+
try {
|
|
134
|
+
const envelope = parseEnvelope(await runCli(['get', ref, ...flagArgs([['--property', 'bounds']])]));
|
|
135
|
+
const value = envelope?.data?.value;
|
|
136
|
+
if (!value) return null;
|
|
137
|
+
const x = Number(value.x), y = Number(value.y), width = Number(value.width), height = Number(value.height);
|
|
138
|
+
if (![x, y, width, height].every(Number.isFinite)) return null;
|
|
139
|
+
return { x: Math.round(x + width / 2), y: Math.round(y + height / 2) };
|
|
140
|
+
} catch { return null; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeTarget(window, pointer) {
|
|
144
|
+
try {
|
|
145
|
+
const payload = JSON.stringify({ window: window ?? null, pointer: pointer ?? null, at: Date.now() });
|
|
146
|
+
if (payload === state.lastTarget) return;
|
|
147
|
+
state.lastTarget = payload;
|
|
148
|
+
mkdirSync(dirname(TARGET_FILE), { recursive: true });
|
|
149
|
+
const temp = `${TARGET_FILE}.${process.pid}.tmp`;
|
|
150
|
+
writeFileSync(temp, payload);
|
|
151
|
+
renameSync(temp, TARGET_FILE);
|
|
152
|
+
} catch { /* the overlay is optional */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function noteTarget(name, args, result) {
|
|
156
|
+
const envelope = result?.structuredContent ?? (result && typeof result === 'object' && 'ok' in result ? result : null);
|
|
157
|
+
const data = envelope?.ok === true ? envelope.data : null;
|
|
158
|
+
if (name === 'launch' && data?.window) {
|
|
159
|
+
rememberWindow(data.window);
|
|
160
|
+
if (data.window.id) state.windowId = data.window.id;
|
|
161
|
+
}
|
|
162
|
+
if (name === 'list_windows') rememberWindows(data);
|
|
163
|
+
if (name === 'snapshot') rememberWindows(data);
|
|
164
|
+
if (args?.window_id) state.windowId = args.window_id;
|
|
165
|
+
if (!state.windowId) return;
|
|
166
|
+
const window = await windowRectFor(state.windowId);
|
|
167
|
+
const ref = args?.ref_id ?? data?.match?.ref_id ?? null;
|
|
168
|
+
let pointer = ref ? await elementPoint(ref) : null;
|
|
169
|
+
if (!pointer && window) pointer = { x: Math.round(window.left + window.width / 2), y: Math.round(window.top + window.height / 2) };
|
|
170
|
+
writeTarget(window, pointer);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ------------------------------------------------------------------ CLI */
|
|
174
|
+
|
|
175
|
+
function runCli(args, { headed = effectiveHeaded() } = {}) {
|
|
176
|
+
const argv = [];
|
|
177
|
+
if (headed) argv.push('--headed');
|
|
178
|
+
argv.push(...args);
|
|
179
|
+
const env = { ...process.env };
|
|
180
|
+
if (state.sessionStarted) env.AGENT_DESKTOP_SESSION = state.sessionId;
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const child = spawn(process.execPath, [CLI_BIN, ...argv], {
|
|
183
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
184
|
+
windowsHide: true,
|
|
185
|
+
env,
|
|
186
|
+
});
|
|
187
|
+
let stdout = '';
|
|
188
|
+
let stderr = '';
|
|
189
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
190
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
191
|
+
child.on('error', (error) => resolve({ code: -1, stdout: '', stderr: String(error?.message || error) }));
|
|
192
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Serialize all CLI calls: the engine's interaction lease allows one driver
|
|
197
|
+
// at a time, and queueing here avoids contending on the kernel lock.
|
|
198
|
+
let queue = Promise.resolve();
|
|
199
|
+
function enqueue(fn) {
|
|
200
|
+
const run = queue.then(fn, fn);
|
|
201
|
+
queue = run.then(() => {}, () => {});
|
|
202
|
+
return run;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseEnvelope({ code, stdout, stderr }) {
|
|
206
|
+
const start = stdout.indexOf('{');
|
|
207
|
+
const end = stdout.lastIndexOf('}');
|
|
208
|
+
if (start !== -1 && end > start) {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(stdout.slice(start, end + 1));
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
version: '2.6',
|
|
215
|
+
ok: false,
|
|
216
|
+
error: {
|
|
217
|
+
code: 'ADAPTER_CHILD_FAILURE',
|
|
218
|
+
message: `agent-desktop exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 400)}` : ''}`,
|
|
219
|
+
details: { kind: 'child_exit', exit_code: code },
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const ELEVATION_HINT =
|
|
225
|
+
' The interaction lease requires the DSH host to run elevated (as Administrator) on this machine. ' +
|
|
226
|
+
'Restart AryaCode as Administrator, then retry. (Lease root: C:\\ProgramData\\agent-desktop\\<user-SID>\\.)';
|
|
227
|
+
|
|
228
|
+
function elevationNote(envelope) {
|
|
229
|
+
const kind = envelope?.error?.details?.kind;
|
|
230
|
+
return kind === 'lease_directory_untrusted' ? ELEVATION_HINT : '';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Headed delivery requires the target window frontmost. A background CLI
|
|
234
|
+
// process cannot SetForegroundWindow (Windows foreground lock), and the
|
|
235
|
+
// engine's focus/focus-window fail with ACTION_FAILED foreground. A physical
|
|
236
|
+
// click at the target's center promotes it to frontmost (OS click handling),
|
|
237
|
+
// then headed delivery succeeds. Returns the failing envelope, or null when
|
|
238
|
+
// the target is frontmost (or the click landed and we proceed optimistically).
|
|
239
|
+
async function ensureForeground(refId) {
|
|
240
|
+
const focused = parseEnvelope(await runCli(['is', refId, '--property', 'focused']));
|
|
241
|
+
if (focused.ok === true && focused.data?.value === true) return null;
|
|
242
|
+
const bounds = parseEnvelope(await runCli(['get', refId, '--property', 'bounds']));
|
|
243
|
+
if (bounds.ok !== true) return bounds;
|
|
244
|
+
const b = bounds.data?.value ?? {};
|
|
245
|
+
const x = Math.round((b.x ?? 0) + (b.width ?? 0) / 2);
|
|
246
|
+
const y = Math.round((b.y ?? 0) + (b.height ?? 0) / 2);
|
|
247
|
+
const click = parseEnvelope(await runCli(['mouse-click', '--xy', `${x},${y}`], { headed: true }));
|
|
248
|
+
if (click.ok !== true) return click;
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function ensureSession() {
|
|
253
|
+
if (state.sessionStarted) return undefined;
|
|
254
|
+
const args = ['session', 'start'];
|
|
255
|
+
if (effectiveOverlay()) args.push('--cursor');
|
|
256
|
+
const envelope = parseEnvelope(await runCli(args));
|
|
257
|
+
if (envelope.ok === true && typeof envelope.data?.session_id === 'string') {
|
|
258
|
+
state.sessionId = envelope.data.session_id;
|
|
259
|
+
state.sessionStarted = true;
|
|
260
|
+
return envelope;
|
|
261
|
+
}
|
|
262
|
+
return envelope;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* -------------------------------------------------------------- results */
|
|
266
|
+
|
|
267
|
+
function envelopeResult(envelope, extraText = '') {
|
|
268
|
+
const note = elevationNote(envelope);
|
|
269
|
+
const text = JSON.stringify(envelope) + extraText + note;
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: 'text', text }],
|
|
272
|
+
structuredContent: envelope,
|
|
273
|
+
isError: envelope.ok !== true,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* -------------------------------------------------------------- tools */
|
|
278
|
+
|
|
279
|
+
const str = { type: 'string' };
|
|
280
|
+
const bool = { type: 'boolean' };
|
|
281
|
+
const int = { type: 'integer' };
|
|
282
|
+
|
|
283
|
+
function tool(name, description, properties, required = []) {
|
|
284
|
+
return {
|
|
285
|
+
name,
|
|
286
|
+
description,
|
|
287
|
+
inputSchema: { type: 'object', properties, required },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const TOOLS = [
|
|
292
|
+
tool('snapshot',
|
|
293
|
+
'Read a window as a semantic tree of refs (@sN:eN). Skeleton first for dense windows, then drill with root. Refs go stale after window changes — re-snapshot on STALE_REF. Chromium windows need timeout_ms 10000-25000 to settle.',
|
|
294
|
+
{ app: str, window_id: str, compact: bool, skeleton: bool, interactive_only: bool, max_depth: int, root: str, timeout_ms: int }),
|
|
295
|
+
tool('find',
|
|
296
|
+
'Search controls by name/role/text/value. Returns refs. Role must be as snapshot prints it (textfield, button, tabitem, listitem — not the native control type name). Use count for counts, first/limit to bound results.',
|
|
297
|
+
{ app: str, window_id: str, name: str, role: str, text: str, value: str, native_id: str, description: str, first: bool, last: bool, nth: int, limit: int, count: bool, exact: bool, root: str, timeout_ms: int }),
|
|
298
|
+
tool('get', 'Read one control: property text|value|title|bounds|role|states.', { ref_id: str, property: str }),
|
|
299
|
+
tool('is', 'Check one control: visible|enabled|checked|focused|expanded|selected.', { ref_id: str, property: str }),
|
|
300
|
+
tool('click', 'Press a control via its semantic action (no pixels). Verify by re-snapshot.', { ref_id: str, timeout_ms: int }),
|
|
301
|
+
tool('set_value', 'Set a control value via UIA SetValue (no keystrokes). Some edits reject this — use type.', { ref_id: str, value: str, timeout_ms: int }),
|
|
302
|
+
tool('type', 'Type keystrokes into a text target. Background accessibility input by default; with headed on it briefly focuses the window and uses real keystrokes.', { ref_id: str, text: str, interval_ms: int, timeout_ms: int }),
|
|
303
|
+
tool('press', 'Send a key chord (e.g. ctrl+s) to an app/window. Foreground queue: for non-frontmost apps expect delivered_unverified.', { combo: str, app: str, window_id: str, force: bool }),
|
|
304
|
+
tool('scroll', 'Scroll a scrollable control: direction up|down|left|right, amount in lines.', { ref_id: str, direction: str, amount: int, timeout_ms: int }),
|
|
305
|
+
tool('list_windows', 'List top-level windows (ids, titles, pids, responsiveness).', { app: str, timeout_ms: int }),
|
|
306
|
+
tool('launch', 'Start an app. Bare names resolve only under System32; otherwise pass the absolute exe path. Ambiguous when many instances run — engine refuses to guess.', { app: str, args: { type: 'array', items: str }, cwd: str, activate: bool, timeout_ms: int }),
|
|
307
|
+
tool('wait', 'Pause (ms) or wait for a condition: window/app/text/element before acting. Prevents acting on half-settled windows.', { ms: int, app: str, window: str, element: str, text: str, timeout_ms: int }),
|
|
308
|
+
tool('screenshot', 'Capture a window/screen PNG to a file; returns the file path. Use for visual evidence only — trees are the primary signal.', { app: str, window_id: str, screen: int }),
|
|
309
|
+
tool('ocr', 'Read text from a screenshot via Windows OCR (for canvas/games/no-a11y-tree UIs). Returns lines with SCREEN coordinates (not refs). query filters lines.', { app: str, window_id: str, screen: int, query: str }),
|
|
310
|
+
tool('batch', `Run 1-${MAX_BATCH} planned actions on one target; stops on first error, then observes once. Each step: {action, arguments}. Actions: snapshot,find,get,is,click,set_value,type,press,scroll,wait.`, { commands_json: str, stop_on_error: bool, timeout_ms: int }, ['commands_json']),
|
|
311
|
+
tool('tools',
|
|
312
|
+
'Manage the adapter: operation list|describe (tool surface + state), load {names} (reveal tools when the lazy preset is on), headed {on} (per-conversation headed toggle — use when headless is not an option: cursor-moving input, notifications, shell surfaces), overlay {on} (presentation cursor overlay), session_end {operation} (end this conversation\'s driver session).',
|
|
313
|
+
{ operation: str, name: str, names: { type: 'array', items: str }, on: bool }),
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
const TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
|
|
317
|
+
|
|
318
|
+
/* -------------------------------------------------------------- dispatch */
|
|
319
|
+
|
|
320
|
+
function flagArgs(pairs) {
|
|
321
|
+
const argv = [];
|
|
322
|
+
for (const [flag, value] of pairs) {
|
|
323
|
+
if (value === undefined || value === null) continue;
|
|
324
|
+
if (typeof value === 'boolean') {
|
|
325
|
+
if (value) argv.push(flag);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (Array.isArray(value)) {
|
|
329
|
+
if (value.length > 0) argv.push(flag, ...value.map(String));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
argv.push(flag, String(value));
|
|
333
|
+
}
|
|
334
|
+
return argv;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function callTool(name, args = {}) {
|
|
338
|
+
switch (name) {
|
|
339
|
+
case 'snapshot':
|
|
340
|
+
return enqueue(async () => {
|
|
341
|
+
await ensureSession();
|
|
342
|
+
return parseEnvelope(await runCli(['snapshot', ...flagArgs([
|
|
343
|
+
['--app', args.app], ['--window-id', args.window_id], ['--compact', args.compact],
|
|
344
|
+
['--skeleton', args.skeleton], ['--interactive-only', args.interactive_only],
|
|
345
|
+
['--max-depth', args.max_depth], ['--root', args.root], ['--timeout-ms', args.timeout_ms],
|
|
346
|
+
])]));
|
|
347
|
+
});
|
|
348
|
+
case 'find':
|
|
349
|
+
return enqueue(async () => {
|
|
350
|
+
await ensureSession();
|
|
351
|
+
return parseEnvelope(await runCli(['find', ...flagArgs([
|
|
352
|
+
['--app', args.app], ['--window-id', args.window_id], ['--name', args.name],
|
|
353
|
+
['--role', args.role], ['--text', args.text], ['--value', args.value],
|
|
354
|
+
['--native-id', args.native_id], ['--description', args.description],
|
|
355
|
+
['--first', args.first], ['--last', args.last], ['--nth', args.nth],
|
|
356
|
+
['--limit', args.limit], ['--count', args.count], ['--exact', args.exact],
|
|
357
|
+
['--root', args.root], ['--timeout-ms', args.timeout_ms],
|
|
358
|
+
])]));
|
|
359
|
+
});
|
|
360
|
+
case 'get':
|
|
361
|
+
return enqueue(async () => parseEnvelope(await runCli(['get', args.ref_id, ...flagArgs([['--property', args.property]])])));
|
|
362
|
+
case 'is':
|
|
363
|
+
return enqueue(async () => parseEnvelope(await runCli(['is', args.ref_id, ...flagArgs([['--property', args.property]])])));
|
|
364
|
+
case 'click':
|
|
365
|
+
return enqueue(async () => {
|
|
366
|
+
await ensureSession();
|
|
367
|
+
return parseEnvelope(await runCli(['click', args.ref_id, ...flagArgs([['--timeout-ms', args.timeout_ms]])]));
|
|
368
|
+
});
|
|
369
|
+
case 'set_value':
|
|
370
|
+
return enqueue(async () => parseEnvelope(await runCli(['set-value', args.ref_id, String(args.value ?? ''), ...flagArgs([['--timeout-ms', args.timeout_ms]])])));
|
|
371
|
+
case 'type':
|
|
372
|
+
return enqueue(async () => {
|
|
373
|
+
await ensureSession();
|
|
374
|
+
const headed = effectiveHeaded();
|
|
375
|
+
if (headed) {
|
|
376
|
+
const promote = await ensureForeground(args.ref_id);
|
|
377
|
+
if (promote) return envelopeResult(promote);
|
|
378
|
+
}
|
|
379
|
+
const env = parseEnvelope(await runCli(['type', args.ref_id, args.text ?? '', ...flagArgs([['--interval-ms', args.interval_ms], ['--timeout-ms', args.timeout_ms]])], { headed }));
|
|
380
|
+
if (!headed && env && env.ok !== true) env.hint = 'Background delivery cannot synthesize keystrokes for this target. Prefer set_value, or enable real input for this conversation: tools {operation:"headed",on:true} (moves the pointer), then type again.';
|
|
381
|
+
return env;
|
|
382
|
+
});
|
|
383
|
+
case 'press':
|
|
384
|
+
return enqueue(async () => {
|
|
385
|
+
await ensureSession();
|
|
386
|
+
return parseEnvelope(await runCli(['press', args.combo, ...flagArgs([['--app', args.app], ['--window-id', args.window_id], ['--force', args.force]])]));
|
|
387
|
+
});
|
|
388
|
+
case 'scroll':
|
|
389
|
+
return enqueue(async () => parseEnvelope(await runCli(['scroll', args.ref_id, ...flagArgs([['--direction', args.direction], ['--amount', args.amount], ['--timeout-ms', args.timeout_ms]])])));
|
|
390
|
+
case 'list_windows':
|
|
391
|
+
return enqueue(async () => parseEnvelope(await runCli(['list-windows', ...flagArgs([['--app', args.app], ['--timeout-ms', args.timeout_ms]])])));
|
|
392
|
+
case 'launch':
|
|
393
|
+
return enqueue(async () => parseEnvelope(await runCli(['launch', args.app, ...flagArgs([['--args', args.args], ['--cwd', args.cwd], ['--activate', args.activate], ['--timeout-ms', args.timeout_ms]])])));
|
|
394
|
+
case 'wait':
|
|
395
|
+
return enqueue(async () => parseEnvelope(await runCli(['wait', ...(args.ms === undefined || args.ms === null ? [] : [String(args.ms)]), ...flagArgs([['--app', args.app], ['--window', args.window], ['--element', args.element], ['--text', args.text], ['--timeout', args.timeout_ms]])])));
|
|
396
|
+
case 'screenshot':
|
|
397
|
+
return enqueue(async () => {
|
|
398
|
+
await ensureSession();
|
|
399
|
+
const file = join(SHOTS_DIR, `shot-${Date.now()}.png`);
|
|
400
|
+
const envelope = parseEnvelope(await runCli(['screenshot', file, ...flagArgs([['--app', args.app], ['--window-id', args.window_id], ['--screen', args.screen]])]));
|
|
401
|
+
return envelopeResult(envelope, envelope.ok ? ` File: ${file}` : '');
|
|
402
|
+
});
|
|
403
|
+
case 'ocr':
|
|
404
|
+
return enqueue(async () => runOcr(args));
|
|
405
|
+
case 'batch': {
|
|
406
|
+
let commands;
|
|
407
|
+
try {
|
|
408
|
+
commands = JSON.parse(args.commands_json);
|
|
409
|
+
} catch {
|
|
410
|
+
return { content: [{ type: 'text', text: 'commands_json is not valid JSON: expected an array of {action, arguments} steps.' }], isError: true };
|
|
411
|
+
}
|
|
412
|
+
if (!Array.isArray(commands) || commands.length < 1 || commands.length > MAX_BATCH) {
|
|
413
|
+
return { content: [{ type: 'text', text: `batch needs 1-${MAX_BATCH} steps (got ${Array.isArray(commands) ? commands.length : 'non-array'}).` }], isError: true };
|
|
414
|
+
}
|
|
415
|
+
const results = [];
|
|
416
|
+
for (const step of commands) {
|
|
417
|
+
const action = String(step?.action || '');
|
|
418
|
+
if (!TOOLS_BY_NAME.has(action) || action === 'batch' || action === 'tools') {
|
|
419
|
+
results.push({ action, error: `unsupported step action: ${action}` });
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
const out = await callTool(action, step?.arguments || {});
|
|
423
|
+
const envelope = out.structuredContent ?? out;
|
|
424
|
+
results.push({ action, ok: envelope.ok === true, summary: summarizeEnvelope(envelope) });
|
|
425
|
+
if (envelope.ok !== true && args.stop_on_error !== false) break;
|
|
426
|
+
}
|
|
427
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: results.every((r) => r.ok), results }) }], isError: !results.every((r) => r.ok) };
|
|
428
|
+
}
|
|
429
|
+
case 'tools':
|
|
430
|
+
return enqueue(async () => {
|
|
431
|
+
const op = args.operation || 'list';
|
|
432
|
+
if (op === 'describe') {
|
|
433
|
+
const t = TOOLS_BY_NAME.get(args.name);
|
|
434
|
+
return { content: [{ type: 'text', text: t ? JSON.stringify(t) : `unknown tool: ${args.name}` }], isError: !t };
|
|
435
|
+
}
|
|
436
|
+
if (op === 'headed') {
|
|
437
|
+
state.headedOverride = args.on === true;
|
|
438
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, headed: effectiveHeaded(), headedDefault: state.headedDefault }) }] };
|
|
439
|
+
}
|
|
440
|
+
if (op === 'overlay') {
|
|
441
|
+
state.overlayOverride = args.on === true;
|
|
442
|
+
if (state.sessionStarted) {
|
|
443
|
+
const env = parseEnvelope(await runCli(['cursor-overlay', state.overlayOverride ? 'enable' : 'disable']));
|
|
444
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: env.ok === true, overlay: effectiveOverlay(), engine: env }) }] };
|
|
445
|
+
}
|
|
446
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, overlay: effectiveOverlay(), note: 'applies at next session start' }) }] };
|
|
447
|
+
}
|
|
448
|
+
if (op === 'load') {
|
|
449
|
+
// Lazy preset only: reveal tools on demand. `names` may name several
|
|
450
|
+
// tools ('all' or no names reveals every remaining tool).
|
|
451
|
+
const requested = Array.isArray(args.names)
|
|
452
|
+
? args.names
|
|
453
|
+
: typeof args.names === 'string' && args.names.trim()
|
|
454
|
+
? args.names.split(/[,\s]+/).filter(Boolean)
|
|
455
|
+
: [];
|
|
456
|
+
const unknown = requested.filter((name) => !TOOLS_BY_NAME.has(name));
|
|
457
|
+
if (unknown.length > 0) {
|
|
458
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, unknown, hint: 'see tools {operation:"describe"} or the tool list' }) }], isError: true };
|
|
459
|
+
}
|
|
460
|
+
const before = exposedTools().length;
|
|
461
|
+
const revealAll = requested.length === 0 || requested.includes('all');
|
|
462
|
+
for (const tool of TOOLS) {
|
|
463
|
+
if (revealAll || requested.includes(tool.name)) state.extraTools.add(tool.name);
|
|
464
|
+
}
|
|
465
|
+
const after = exposedTools().length;
|
|
466
|
+
if (after !== before) notifyToolsChanged();
|
|
467
|
+
return {
|
|
468
|
+
content: [{
|
|
469
|
+
type: 'text',
|
|
470
|
+
text: JSON.stringify({
|
|
471
|
+
ok: true,
|
|
472
|
+
preset: state.toolPreset,
|
|
473
|
+
revealed: after - before,
|
|
474
|
+
tools: exposedTools().map((tool) => tool.name),
|
|
475
|
+
}),
|
|
476
|
+
}],
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
if (op === 'session_end') {
|
|
480
|
+
const env = parseEnvelope(await runCli(['session', 'end']));
|
|
481
|
+
await runCli(['session', 'gc']);
|
|
482
|
+
state.sessionStarted = false;
|
|
483
|
+
state.sessionId = `arya-${randomBytes(4).toString('hex')}`;
|
|
484
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: env.ok === true, note: 'new session starts on next action', engine: env }) }] };
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
content: [{
|
|
488
|
+
type: 'text',
|
|
489
|
+
text: JSON.stringify({
|
|
490
|
+
tools: exposedTools().map((t) => t.name),
|
|
491
|
+
preset: state.toolPreset,
|
|
492
|
+
hidden: state.toolPreset === 'lazy' ? TOOLS.length - exposedTools().length : 0,
|
|
493
|
+
state: { session: state.sessionId, sessionStarted: state.sessionStarted, headed: effectiveHeaded(), headedDefault: state.headedDefault, overlay: effectiveOverlay(), overlayDefault: state.overlayDefault },
|
|
494
|
+
engine_state_root: ENGINE_STATE_ROOT,
|
|
495
|
+
}),
|
|
496
|
+
}],
|
|
497
|
+
};
|
|
498
|
+
});
|
|
499
|
+
default:
|
|
500
|
+
return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true };
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function summarizeEnvelope(envelope) {
|
|
505
|
+
if (envelope?.ok === true) {
|
|
506
|
+
const d = envelope.data ?? {};
|
|
507
|
+
const bits = [];
|
|
508
|
+
if (d.ref_count !== undefined) bits.push(`refs ${d.ref_count}`);
|
|
509
|
+
if (d.window?.id) bits.push(`window ${d.window.id}`);
|
|
510
|
+
if (d.target?.process?.pid) bits.push(`pid ${d.target.process.pid}`);
|
|
511
|
+
if (d.disposition?.delivery) bits.push(`delivery ${d.disposition.delivery}`);
|
|
512
|
+
return bits.join('; ') || 'ok';
|
|
513
|
+
}
|
|
514
|
+
return `${envelope?.error?.code || 'ERROR'}: ${String(envelope?.error?.message || '').slice(0, 200)}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function runOcr(args) {
|
|
518
|
+
const file = join(SHOTS_DIR, `ocr-${Date.now()}.png`);
|
|
519
|
+
const shot = parseEnvelope(await runCli(['screenshot', file, ...flagArgs([['--app', args.app], ['--window-id', args.window_id], ['--screen', args.screen]])]));
|
|
520
|
+
if (shot.ok !== true) return envelopeResult(shot);
|
|
521
|
+
const ps = await new Promise((resolve) => {
|
|
522
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', OCR_PS1, '-ImagePath', file], {
|
|
523
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
524
|
+
windowsHide: true,
|
|
525
|
+
});
|
|
526
|
+
let stdout = '';
|
|
527
|
+
let stderr = '';
|
|
528
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
529
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
530
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
531
|
+
});
|
|
532
|
+
const start = ps.stdout.indexOf('{');
|
|
533
|
+
const end = ps.stdout.lastIndexOf('}');
|
|
534
|
+
if (start === -1 || end <= start) {
|
|
535
|
+
return {
|
|
536
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: `ocr.ps1 exited ${ps.code}`, stderr: ps.stderr.slice(0, 400), screenshot: file }) }],
|
|
537
|
+
isError: true,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
let parsed;
|
|
541
|
+
try {
|
|
542
|
+
parsed = JSON.parse(ps.stdout.slice(start, end + 1));
|
|
543
|
+
} catch {
|
|
544
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'ocr.ps1 output not JSON', screenshot: file }) }], isError: true };
|
|
545
|
+
}
|
|
546
|
+
if (args.query && Array.isArray(parsed.lines)) {
|
|
547
|
+
const needle = String(args.query).toLowerCase();
|
|
548
|
+
parsed.lines = parsed.lines.filter((l) => String(l.text || '').toLowerCase().includes(needle));
|
|
549
|
+
parsed.query = args.query;
|
|
550
|
+
}
|
|
551
|
+
parsed.screenshot = file;
|
|
552
|
+
return { content: [{ type: 'text', text: JSON.stringify(parsed) }], isError: parsed.ok !== true };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* -------------------------------------------------------------- protocol */
|
|
556
|
+
|
|
557
|
+
const INSTRUCTIONS = [
|
|
558
|
+
'Arya Computer Use: drive Windows apps through semantic UIA refs, not pixels.',
|
|
559
|
+
'Loop: snapshot (skeleton/compact first) -> act on @refs -> re-snapshot to verify.',
|
|
560
|
+
'Refs go stale after window changes: on STALE_REF/AMBIGUOUS_TARGET, re-snapshot.',
|
|
561
|
+
'type uses background accessibility input by default and never moves the pointer; tools {operation:"headed",on:true} switches this conversation to real keyboard/mouse input when a surface needs it.',
|
|
562
|
+
'Chromium windows settle slowly: snapshot with timeout_ms 10000-25000.',
|
|
563
|
+
'Verify stateful actions by re-read; delivered_verified = confirmed, delivered_unverified retry:unsafe = do not blind-repeat.',
|
|
564
|
+
'On ACTION_FAILED post_action_verification the action landed: read data.post_state, do NOT retry.',
|
|
565
|
+
'This host must run elevated (Administrator); lease_directory_untrusted means restart AryaCode as Administrator.',
|
|
566
|
+
'Destructive actions (delete, send, pay, close unsaved) require user confirmation first.',
|
|
567
|
+
'No accessibility tree (games/canvas): use ocr for text and headed mouse coordinates for input.',
|
|
568
|
+
].join('\n');
|
|
569
|
+
|
|
570
|
+
const server = { name: 'arya_desktop', version: VERSION };
|
|
571
|
+
|
|
572
|
+
function send(msg) {
|
|
573
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function reply(id, result) {
|
|
577
|
+
send({ jsonrpc: '2.0', id, result });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function replyError(id, code, message) {
|
|
581
|
+
send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
585
|
+
rl.on('line', (line) => {
|
|
586
|
+
let msg;
|
|
587
|
+
try {
|
|
588
|
+
msg = JSON.parse(line);
|
|
589
|
+
} catch {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const { id, method, params } = msg;
|
|
593
|
+
if (method === 'initialize') {
|
|
594
|
+
reply(id, {
|
|
595
|
+
protocolVersion: '2025-06-18',
|
|
596
|
+
capabilities: { tools: { listChanged: true } },
|
|
597
|
+
serverInfo: server,
|
|
598
|
+
instructions: INSTRUCTIONS,
|
|
599
|
+
});
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (method === 'notifications/initialized' || method?.startsWith('notifications/')) return;
|
|
603
|
+
if (method === 'ping') {
|
|
604
|
+
reply(id, {});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (method === 'tools/list') {
|
|
608
|
+
reply(id, { tools: exposedTools() });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (method === 'tools/call') {
|
|
612
|
+
const name = params?.name;
|
|
613
|
+
const args = params?.arguments ?? {};
|
|
614
|
+
Promise.resolve()
|
|
615
|
+
.then(() => callTool(name, args))
|
|
616
|
+
.then(async (out) => {
|
|
617
|
+
try { await noteTarget(name, args, out); } catch { /* overlay hint only */ }
|
|
618
|
+
return out;
|
|
619
|
+
})
|
|
620
|
+
.then((out) => reply(id, Array.isArray(out?.content) ? out : envelopeResult(out)))
|
|
621
|
+
.catch((error) => reply(id, { content: [{ type: 'text', text: `adapter error: ${error?.message || error}` }], isError: true }));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (id !== undefined) replyError(id, -32601, `method not supported: ${method}`);
|
|
625
|
+
});
|
|
626
|
+
process.stdin.on('end', () => process.exit(0));
|