@vortex-os/computer-use 0.1.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/README.md +74 -0
- package/computer-use.config.example.json +12 -0
- package/package.json +50 -0
- package/scripts/lib.ps1 +617 -0
- package/scripts/mcp-stdio.mjs +546 -0
- package/scripts/point-to-ask.ps1 +28 -0
- package/scripts/probe.ps1 +6 -0
- package/scripts/read-ui.ps1 +13 -0
- package/scripts/worker.ps1 +84 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// computer-use — MCP stdio server (PoC). Exposes the CLI dispatcher (action-ext.mjs) as MCP tools.
|
|
3
|
+
// Same backend logic (OS-native). Tools: probe · read_ui · capture_screen.
|
|
4
|
+
// Depends on: @modelcontextprotocol/sdk (pulled in by memory-extended, present in the instance node_modules).
|
|
5
|
+
// In the real add-on this ships packaged as scripts/mcp-stdio.mjs + bin vortex-mcp-action (design §21).
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { readFileSync, unlinkSync, statSync, mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
13
|
+
import { tmpdir, homedir } from 'node:os';
|
|
14
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const plat = process.platform;
|
|
18
|
+
|
|
19
|
+
// ── redaction config (§8·§14) ─────────────────────────────────────────────
|
|
20
|
+
// Normalize the denylist into env (JSON array) so children (worker / per-call spawn) inherit it -> no per-call args. Config source: env > config file.
|
|
21
|
+
// The actual blocking is done by the backend (lib.ps1 Test-AxDenylist) right before CopyFromScreen (Node doesn't know which windows are inside the region/monitor).
|
|
22
|
+
function loadRedactionConfig() {
|
|
23
|
+
let titles = [], procs = [];
|
|
24
|
+
try {
|
|
25
|
+
const cfgPath = join(dir, 'computer-use.config.json');
|
|
26
|
+
if (existsSync(cfgPath)) {
|
|
27
|
+
const r = (JSON.parse(readFileSync(cfgPath, 'utf8')) || {}).redaction || {};
|
|
28
|
+
if (Array.isArray(r.denyWindowTitles)) titles = r.denyWindowTitles;
|
|
29
|
+
if (Array.isArray(r.denyProcesses)) procs = r.denyProcesses;
|
|
30
|
+
}
|
|
31
|
+
} catch {}
|
|
32
|
+
try { if (process.env.VORTEX_CU_DENY_TITLES) titles = JSON.parse(process.env.VORTEX_CU_DENY_TITLES); } catch {}
|
|
33
|
+
try { if (process.env.VORTEX_CU_DENY_PROCS) procs = JSON.parse(process.env.VORTEX_CU_DENY_PROCS); } catch {}
|
|
34
|
+
titles = (Array.isArray(titles) ? titles : []).map(String).filter(Boolean);
|
|
35
|
+
procs = (Array.isArray(procs) ? procs : []).map(String).filter(Boolean);
|
|
36
|
+
process.env.VORTEX_CU_DENY_TITLES = JSON.stringify(titles); // re-export for child inheritance (after normalization)
|
|
37
|
+
process.env.VORTEX_CU_DENY_PROCS = JSON.stringify(procs);
|
|
38
|
+
return { titles, procs };
|
|
39
|
+
}
|
|
40
|
+
const REDACTION = loadRedactionConfig();
|
|
41
|
+
|
|
42
|
+
// ── audit log (§8: metadata/HMAC only, original image not stored) ──────────────────────
|
|
43
|
+
// Location = under LocalAppData (outside the instance data/ -> won't leak via corporate sync, codex MEDIUM). The key lives there too.
|
|
44
|
+
const AUDIT_DIR = join(process.env.LOCALAPPDATA || join(homedir(), '.local', 'share'), 'vortex-computer-use', 'audit');
|
|
45
|
+
function loadAuditKey() {
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
48
|
+
const kp = join(AUDIT_DIR, '.hmac-key');
|
|
49
|
+
if (existsSync(kp)) { const k = readFileSync(kp, 'utf8').trim(); if (k) return k; }
|
|
50
|
+
const k = randomBytes(32).toString('hex');
|
|
51
|
+
writeFileSync(kp, k, { mode: 0o600 }); // Windows ignores mode, but LocalAppData is per-user
|
|
52
|
+
return k;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// Auditing is a record, not an access control — a failed key setup must not block perception (capture); local tool availability. But don't swallow it silently — warn (codex r2 MEDIUM).
|
|
55
|
+
process.stderr.write(`[computer-use MCP] WARNING: audit log disabled — could not set up HMAC key (${(e && e.message) || e}). Perception still works; captures will NOT be audited.\n`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const AUDIT_KEY = loadAuditKey();
|
|
60
|
+
function auditHmac(buf) { return createHmac('sha256', AUDIT_KEY).update(buf).digest('hex'); }
|
|
61
|
+
function auditLog(tool, payload, imageItems) {
|
|
62
|
+
if (!AUDIT_KEY) return; // skip silently if key setup failed (the tool keeps working)
|
|
63
|
+
try {
|
|
64
|
+
const p = payload || {};
|
|
65
|
+
// Detect not only top-level redacted/partialRedacted but also the nested captures[].redacted of a multi-frame watch (codex r3 MEDIUM).
|
|
66
|
+
const isRed = !!(p.redacted || p.partialRedacted || (Array.isArray(p.captures) && p.captures.some((f) => f && f.redacted)));
|
|
67
|
+
const rec = {
|
|
68
|
+
ts: new Date().toISOString(), tool,
|
|
69
|
+
mode: typeof p.target === 'string' ? p.target : undefined,
|
|
70
|
+
titleHmac: p.window ? auditHmac(String(p.window)).slice(0, 16) : undefined, // window title not stored in plaintext (HMAC only, A4-1)
|
|
71
|
+
redacted: isRed, reason: p.reason || undefined,
|
|
72
|
+
outputBytes: 0, contentHmac: undefined,
|
|
73
|
+
};
|
|
74
|
+
const h = createHmac('sha256', AUDIT_KEY);
|
|
75
|
+
if (imageItems && imageItems.length) {
|
|
76
|
+
for (const im of imageItems) { const b = Buffer.from(im.data, 'base64'); rec.outputBytes += b.length; h.update(b); }
|
|
77
|
+
} else { h.update(JSON.stringify(p)); } // JSON.stringify blocks JSONL injection (newlines / control chars) (codex MEDIUM)
|
|
78
|
+
rec.contentHmac = h.digest('hex').slice(0, 32);
|
|
79
|
+
const day = rec.ts.slice(0, 10);
|
|
80
|
+
appendFileSync(join(AUDIT_DIR, `cu-${day}.jsonl`), JSON.stringify(rec) + '\n');
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// OS-native backend (same as action-ext.mjs — a single source would be ideal, but it's duplicated since this is a PoC)
|
|
85
|
+
const B = {
|
|
86
|
+
win32: {
|
|
87
|
+
probe: ['pwsh', ['-NoProfile', '-File', join(dir, 'probe.ps1')]],
|
|
88
|
+
read: ['pwsh', ['-NoProfile', '-File', join(dir, 'read-ui.ps1')]],
|
|
89
|
+
capture: ['pwsh', ['-NoProfile', '-File', join(dir, 'point-to-ask.ps1')]],
|
|
90
|
+
},
|
|
91
|
+
darwin: {
|
|
92
|
+
probe: ['bash', [join(dir, 'mac', 'probe.sh')]],
|
|
93
|
+
read: ['osascript', ['-l', 'JavaScript', join(dir, 'mac', 'read-ui.js')]],
|
|
94
|
+
capture: ['bash', [join(dir, 'mac', 'point-to-ask.sh')]],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Returns: { payload, isError } — backend abnormal exit / non-JSON output is surfaced as isError (so an error never flows as if normal in the watch loop).
|
|
99
|
+
function runBackend(kind, extraArgs = []) {
|
|
100
|
+
const b = B[plat]?.[kind];
|
|
101
|
+
if (!b) return { payload: { error: `unsupported platform/op: ${plat}/${kind}`, grade: 'P0 (manual) fallback' }, isError: true };
|
|
102
|
+
const [exe, base] = b;
|
|
103
|
+
const r = spawnSync(exe, [...base, ...extraArgs], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
|
|
104
|
+
if (r.error) return { payload: { error: String(r.error) }, isError: true };
|
|
105
|
+
const failed = r.status !== 0;
|
|
106
|
+
try {
|
|
107
|
+
return { payload: JSON.parse(r.stdout), isError: failed };
|
|
108
|
+
} catch {
|
|
109
|
+
return { payload: { error: 'backend produced non-JSON output', exitCode: r.status, raw: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }, isError: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Async backend runner for long/streaming ops (watch_capture). Never blocks the event loop, and a hard
|
|
114
|
+
// timeout kills the child and returns isError — so one watch call can't hang the whole MCP server (codex #high).
|
|
115
|
+
function runBackendAsync(kind, extraArgs = [], timeoutMs = 120000, cleanupDir = null) {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
const b = B[plat]?.[kind];
|
|
118
|
+
if (!b) return resolve({ payload: { error: `unsupported platform/op: ${plat}/${kind}`, grade: 'P0 (manual) fallback' }, isError: true });
|
|
119
|
+
const [exe, base] = b;
|
|
120
|
+
const MAX_OUT = 16 * 1024 * 1024; // cap stdout/stderr accumulation; abort on overflow (codex #med DoS)
|
|
121
|
+
let out = '', err = '', settled = false, pending = null, killFallback = null;
|
|
122
|
+
const child = spawn(exe, [...base, ...extraArgs], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
123
|
+
const resolveOnce = (payload, isError, cleanupOwned = false) => {
|
|
124
|
+
if (settled) return; settled = true;
|
|
125
|
+
clearTimeout(timer); if (killFallback) clearTimeout(killFallback);
|
|
126
|
+
resolve({ payload, isError, cleanupOwned });
|
|
127
|
+
};
|
|
128
|
+
// Abort = decide the outcome + kill the child, but RESOLVE only after the child has CLOSED (file handles released)
|
|
129
|
+
// so the caller's reqDir cleanup can't race a still-flushing process (codex #high). If close never arrives within the
|
|
130
|
+
// grace window, resolve anyway but TAKE OWNERSHIP of reqDir cleanup (deferred to the eventual close, with a hard reaper)
|
|
131
|
+
// and signal cleanupOwned so the caller skips its own rmSync — we never delete a dir a live pwsh might still hold.
|
|
132
|
+
const abort = (payload) => {
|
|
133
|
+
if (pending || settled) return;
|
|
134
|
+
pending = payload;
|
|
135
|
+
try { child.kill(); } catch {}
|
|
136
|
+
killFallback = setTimeout(() => {
|
|
137
|
+
if (cleanupDir) {
|
|
138
|
+
child.once('close', () => { try { rmSync(cleanupDir, { recursive: true, force: true }); } catch {} });
|
|
139
|
+
const reaper = setTimeout(() => { try { rmSync(cleanupDir, { recursive: true, force: true }); } catch {} }, 30000);
|
|
140
|
+
if (reaper.unref) reaper.unref();
|
|
141
|
+
}
|
|
142
|
+
resolveOnce(pending, true, cleanupDir != null);
|
|
143
|
+
}, 3000);
|
|
144
|
+
if (killFallback.unref) killFallback.unref();
|
|
145
|
+
};
|
|
146
|
+
const timer = setTimeout(() => abort({ error: `watch timed out after ${timeoutMs}ms`, partial: (out || '').slice(0, 200) }), timeoutMs);
|
|
147
|
+
child.stdout.setEncoding('utf8'); child.stdout.on('data', (d) => { if (pending || settled) return; out += d; if (out.length > MAX_OUT) abort({ error: 'backend stdout exceeded cap' }); });
|
|
148
|
+
child.stderr.setEncoding('utf8'); child.stderr.on('data', (d) => { if (pending || settled || err.length >= MAX_OUT) return; err += d; });
|
|
149
|
+
child.on('error', (e) => resolveOnce({ error: String((e && e.message) || e) }, true));
|
|
150
|
+
child.on('close', (code) => {
|
|
151
|
+
if (pending) return resolveOnce(pending, true); // aborted AND child closed within grace → caller can clean safely
|
|
152
|
+
if (settled) return;
|
|
153
|
+
try { resolveOnce(JSON.parse(out), code !== 0); }
|
|
154
|
+
catch { resolveOnce({ error: 'backend produced non-JSON output', exitCode: code, raw: (out || '').trim().slice(0, 500), stderr: (err || '').trim().slice(0, 500) }, true); }
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── resident PowerShell worker (Windows MCP only) ─────────────────────────────
|
|
160
|
+
// Removes the per-call pwsh re-spawn (~150-370ms setup) — keeps one worker alive and talks to it over JSON-lines.
|
|
161
|
+
// Safeguards (codex cross-check): lazy start (spawn only on first call) · idle shutdown (N seconds with no command) ·
|
|
162
|
+
// single-worker command queue serialization · id matching · a generation (= process identity) guard to ignore stale events ·
|
|
163
|
+
// on crash, reject in-flight (no auto-retry of side-effect ops) · on no-response timeout, kill the worker and re-spawn.
|
|
164
|
+
// Multi-instance: each MCP server has its own worker (dedicated pipe) -> no conflicts. On parent exit the
|
|
165
|
+
// worker auto-terminates via stdin EOF. watch isn't run on the worker (avoids long occupation) — done per-call.
|
|
166
|
+
const WORKER = ['pwsh', ['-NoProfile', '-File', join(dir, 'worker.ps1')]];
|
|
167
|
+
const WORKER_IDLE_MS = Number(process.env.VORTEX_AX_WORKER_IDLE_MS || 60000);
|
|
168
|
+
const OP_TIMEOUT_MS = Number(process.env.VORTEX_AX_OP_TIMEOUT_MS || 10000);
|
|
169
|
+
|
|
170
|
+
class WorkerManager {
|
|
171
|
+
constructor([exe, args]) { this.exe = exe; this.args = args; this.worker = null; this.buf = ''; this.seq = 0; this.inFlight = null; this.queue = []; this.idleTimer = null; }
|
|
172
|
+
|
|
173
|
+
call(op, args, timeoutMs = OP_TIMEOUT_MS) {
|
|
174
|
+
return new Promise((resolve, reject) => { this.queue.push({ op, args, timeoutMs, resolve, reject }); this._pump(); });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_spawn() {
|
|
178
|
+
const child = spawn(this.exe, this.args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
179
|
+
this.worker = child; this.buf = '';
|
|
180
|
+
child.stdout.setEncoding('utf8');
|
|
181
|
+
child.stdout.on('data', (chunk) => {
|
|
182
|
+
if (child !== this.worker) return; // generation guard: ignore the old worker
|
|
183
|
+
this.buf += chunk;
|
|
184
|
+
let nl;
|
|
185
|
+
while ((nl = this.buf.indexOf('\n')) >= 0) {
|
|
186
|
+
const line = this.buf.slice(0, nl).trim();
|
|
187
|
+
this.buf = this.buf.slice(nl + 1);
|
|
188
|
+
if (line) this._onLine(line);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
child.stderr.on('data', (d) => process.stderr.write(`[ax-worker] ${d}`));
|
|
192
|
+
child.stdin.on('error', () => {}); // EPIPE etc. are cleaned up in exit/error — here we only prevent an unhandled crash
|
|
193
|
+
child.on('error', (err) => this._onGone(child, `worker spawn/runtime error: ${err && err.message}`));
|
|
194
|
+
child.on('exit', (code) => this._onGone(child, `worker exited (code ${code}) before responding`));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_onGone(child, reason) {
|
|
198
|
+
if (child !== this.worker) return; // generation guard: ignore an old / already-replaced worker
|
|
199
|
+
this.worker = null;
|
|
200
|
+
const f = this.inFlight;
|
|
201
|
+
if (f) { clearTimeout(f.timer); this.inFlight = null; f.reject(new Error(reason)); }
|
|
202
|
+
if (this.queue.length) this._pump(); // run the backlog on a new worker (no auto-retry of side-effect ops)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_onLine(line) {
|
|
206
|
+
let msg;
|
|
207
|
+
try { msg = JSON.parse(line); } catch { process.stderr.write(`[ax-worker] non-JSON line dropped: ${line.slice(0, 120)}\n`); return; }
|
|
208
|
+
const f = this.inFlight;
|
|
209
|
+
if (!f || msg.id !== f.id) return; // stale/mismatch
|
|
210
|
+
clearTimeout(f.timer); this.inFlight = null;
|
|
211
|
+
if (msg.ok) f.resolve(msg.result); else f.reject(new Error(msg.error || 'worker error'));
|
|
212
|
+
this._pump();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_pump() {
|
|
216
|
+
if (this.inFlight) return;
|
|
217
|
+
if (this.queue.length === 0) { this._scheduleIdle(); return; }
|
|
218
|
+
if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; }
|
|
219
|
+
if (!this.worker) this._spawn();
|
|
220
|
+
const job = this.queue.shift();
|
|
221
|
+
const id = ++this.seq;
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
if (this.inFlight && this.inFlight.id === id) { // no response -> assume the worker is stuck and kill it
|
|
224
|
+
const w = this.worker; this.worker = null;
|
|
225
|
+
const rej = this.inFlight.reject; this.inFlight = null;
|
|
226
|
+
if (w) { try { w.kill(); } catch {} }
|
|
227
|
+
rej(new Error(`worker op timeout after ${job.timeoutMs}ms`));
|
|
228
|
+
this._pump();
|
|
229
|
+
}
|
|
230
|
+
}, job.timeoutMs);
|
|
231
|
+
this.inFlight = { id, resolve: job.resolve, reject: job.reject, timer };
|
|
232
|
+
try { this.worker.stdin.write(JSON.stringify({ id, op: job.op, args: job.args }) + '\n'); }
|
|
233
|
+
catch (e) {
|
|
234
|
+
clearTimeout(timer); this.inFlight = null;
|
|
235
|
+
const w = this.worker; this.worker = null;
|
|
236
|
+
if (w) { try { w.kill(); } catch {} }
|
|
237
|
+
job.reject(e); this._pump(); // assume the worker is dead, clean up, and proceed to the next request (prevents queue stall)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_scheduleIdle() {
|
|
242
|
+
if (this.idleTimer || !this.worker) return;
|
|
243
|
+
this.idleTimer = setTimeout(() => { this.idleTimer = null; if (!this.inFlight && this.queue.length === 0) this.dispose(); }, WORKER_IDLE_MS);
|
|
244
|
+
if (this.idleTimer.unref) this.idleTimer.unref();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
dispose() {
|
|
248
|
+
if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; }
|
|
249
|
+
const w = this.worker; this.worker = null;
|
|
250
|
+
if (w) { try { w.stdin.end(); } catch {} try { w.kill(); } catch {} }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const workerMgr = new WorkerManager(WORKER);
|
|
255
|
+
|
|
256
|
+
// watchIds this server process has dispatched a poll_change for — to detect a SILENT baseline reset
|
|
257
|
+
// (the resident worker was idle-disposed / killed / crashed and lost its in-memory watch state). codex #high.
|
|
258
|
+
const pollSeen = new Set();
|
|
259
|
+
|
|
260
|
+
// Server-owned volatility (codex blocker, design §8): when a backend wrote screenshot file(s), read them inline as
|
|
261
|
+
// MCP image content and DELETE them immediately — no on-disk path is returned, so a crashed/idle caller can't leave
|
|
262
|
+
// sensitive screenshots behind. Bounded: at most MAX_INLINE_IMAGES are embedded; extras are still unlinked + counted.
|
|
263
|
+
const MAX_INLINE_IMAGES = 8;
|
|
264
|
+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // per-image cap (codex #med — bound response size, avoid blocking read)
|
|
265
|
+
const MAX_TOTAL_INLINE_BYTES = 24 * 1024 * 1024; // total inline cap across a response
|
|
266
|
+
function materializeImages(payload) {
|
|
267
|
+
const images = [];
|
|
268
|
+
let inlined = 0, dropped = 0, totalBytes = 0;
|
|
269
|
+
const take = (p) => {
|
|
270
|
+
if (!p || typeof p !== 'string') return false;
|
|
271
|
+
let ok = false;
|
|
272
|
+
try {
|
|
273
|
+
const sz = statSync(p).size;
|
|
274
|
+
if (inlined < MAX_INLINE_IMAGES && sz <= MAX_IMAGE_BYTES && totalBytes + sz <= MAX_TOTAL_INLINE_BYTES) {
|
|
275
|
+
images.push({ type: 'image', data: readFileSync(p).toString('base64'), mimeType: 'image/png' });
|
|
276
|
+
inlined++; totalBytes += sz; ok = true;
|
|
277
|
+
} else { dropped++; }
|
|
278
|
+
} catch { /* file already gone — fine */ }
|
|
279
|
+
try { unlinkSync(p); } catch {}
|
|
280
|
+
return ok;
|
|
281
|
+
};
|
|
282
|
+
if (payload && typeof payload === 'object') {
|
|
283
|
+
if (payload.path) { const g = take(payload.path); delete payload.path; payload.image = g ? 'inline' : 'unavailable'; }
|
|
284
|
+
if (Array.isArray(payload.captures)) {
|
|
285
|
+
for (const f of payload.captures) { if (f && f.path) { const g = take(f.path); delete f.path; f.image = g ? 'inline' : 'unavailable'; } }
|
|
286
|
+
}
|
|
287
|
+
if (dropped > 0) payload.imagesDropped = dropped;
|
|
288
|
+
}
|
|
289
|
+
return images;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function viaWorker(op, args, timeoutMs) {
|
|
293
|
+
try { return { payload: await workerMgr.call(op, args, timeoutMs), isError: false }; }
|
|
294
|
+
catch (e) { return { payload: { error: String((e && e.message) || e) }, isError: true }; }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const TOOLS = [
|
|
298
|
+
{
|
|
299
|
+
name: 'probe',
|
|
300
|
+
description: 'Check whether screen perception works in this environment + measure display/capture latency (P0.5).',
|
|
301
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'read_ui',
|
|
305
|
+
description: 'Structured perception of the active/targeted window — UIA(Win)/AX(mac) tree: elements, roles, coordinates, text. Zero images, ~0 tokens (P1 primary).',
|
|
306
|
+
inputSchema: {
|
|
307
|
+
type: 'object',
|
|
308
|
+
properties: {
|
|
309
|
+
window: { type: 'string', description: 'Target by a substring of the window title. If omitted, foreground.' },
|
|
310
|
+
target: { type: 'string', enum: ['foreground', 'cursor'], description: 'Target to use when no window is given.' },
|
|
311
|
+
},
|
|
312
|
+
additionalProperties: false,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: 'capture_screen',
|
|
317
|
+
description: 'Pixel capture — fallback for canvas/games that structured perception cannot read. Target (priority): region > window > monitor > (default) around the cursor. Returns a PNG path (volatility is the caller\'s job, §8).',
|
|
318
|
+
inputSchema: {
|
|
319
|
+
type: 'object',
|
|
320
|
+
properties: {
|
|
321
|
+
boxW: { type: 'number', description: 'Cursor-mode box width (default 600).' },
|
|
322
|
+
boxH: { type: 'number', description: 'Cursor-mode box height (default 400).' },
|
|
323
|
+
region: {
|
|
324
|
+
type: 'object',
|
|
325
|
+
description: 'Capture a fixed region (ignores cursor). Virtual-screen physical coordinates.',
|
|
326
|
+
properties: { x: { type: 'number' }, y: { type: 'number' }, w: { type: 'number' }, h: { type: 'number' } },
|
|
327
|
+
required: ['x', 'y', 'w', 'h'],
|
|
328
|
+
additionalProperties: false,
|
|
329
|
+
},
|
|
330
|
+
window: { type: 'string', description: 'Window title substring -> capture that window\'s region (ignores cursor). Tracks the window even as it moves.' },
|
|
331
|
+
monitor: { type: 'string', description: "Target a monitor (ignores cursor): 1-based index ('3') or 'primary'. For continuously watching a game-only monitor." },
|
|
332
|
+
detail: { type: 'string', description: "Resolution preset: 'gist' (flow only, small) / 'normal' (default) / 'text' (text/code, large). For reading text, prefer region/window + 'text' over a whole monitor." },
|
|
333
|
+
scale: { type: 'number', description: 'Upscale factor (small regions). When set, overrides detail.' },
|
|
334
|
+
maxSide: { type: 'number', description: 'Upper bound on the output longest side in px. When set, overrides detail.' },
|
|
335
|
+
},
|
|
336
|
+
additionalProperties: false,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: 'watch_capture',
|
|
341
|
+
description: 'Capture a fixed target N times at a set interval (within one pwsh process — avoids per-frame re-spawn cost). With changeOnly, save only frames that changed from the previous one. Synchronous/blocking (waits for the response over frames×interval) — Windows-only PoC. Target priority: region > window > monitor > cursor.',
|
|
342
|
+
inputSchema: {
|
|
343
|
+
type: 'object',
|
|
344
|
+
properties: {
|
|
345
|
+
frames: { type: 'number', description: 'Number of frames to capture (>1).' },
|
|
346
|
+
intervalMs: { type: 'number', description: 'Interval between frames (ms, default 1000).' },
|
|
347
|
+
changeOnly: { type: 'boolean', description: 'Save only frames that changed from the previous one (default false).' },
|
|
348
|
+
changeThreshold: { type: 'number', description: 'Change-detection threshold % (default 2.0).' },
|
|
349
|
+
region: {
|
|
350
|
+
type: 'object',
|
|
351
|
+
description: 'Fixed region (ignores cursor). Virtual-screen physical coordinates.',
|
|
352
|
+
properties: { x: { type: 'number' }, y: { type: 'number' }, w: { type: 'number' }, h: { type: 'number' } },
|
|
353
|
+
required: ['x', 'y', 'w', 'h'],
|
|
354
|
+
additionalProperties: false,
|
|
355
|
+
},
|
|
356
|
+
window: { type: 'string', description: 'Window title substring (ignores cursor, tracks movement).' },
|
|
357
|
+
monitor: { type: 'string', description: "Target a monitor (ignores cursor): index or 'primary'." },
|
|
358
|
+
boxW: { type: 'number', description: 'Cursor-mode box width.' },
|
|
359
|
+
boxH: { type: 'number', description: 'Cursor-mode box height.' },
|
|
360
|
+
detail: { type: 'string', description: "Resolution preset: 'gist'/'normal'/'text'. An explicit scale/maxSide takes precedence." },
|
|
361
|
+
scale: { type: 'number', description: 'Upscale factor. When set, overrides detail.' },
|
|
362
|
+
maxSide: { type: 'number', description: 'Upper bound on the output longest side in px. When set, overrides detail.' },
|
|
363
|
+
},
|
|
364
|
+
additionalProperties: false,
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: 'poll_change',
|
|
369
|
+
description: 'Look at the screen once (async watch primitive) — capture the target once, compare with the previous shot, and immediately return only the change rate. The resident worker remembers the previous state per watchId (continuity). **By default, metadata only (changed, changePct; no image saved = token savings)** — pass includeImage:true to also get the image (path) only when you actually need to see the screen. "Watching alongside" is built by the agent repeatedly calling this tool (polling) every 1-2 seconds — non-blocking, the user can step in at any time. Use reset:true on start / target change. Windows-only.',
|
|
370
|
+
inputSchema: {
|
|
371
|
+
type: 'object',
|
|
372
|
+
properties: {
|
|
373
|
+
region: {
|
|
374
|
+
type: 'object',
|
|
375
|
+
description: 'Fixed region (ignores cursor). Virtual-screen physical coordinates.',
|
|
376
|
+
properties: { x: { type: 'number' }, y: { type: 'number' }, w: { type: 'number' }, h: { type: 'number' } },
|
|
377
|
+
required: ['x', 'y', 'w', 'h'],
|
|
378
|
+
additionalProperties: false,
|
|
379
|
+
},
|
|
380
|
+
window: { type: 'string', description: 'Window title substring (ignores cursor, tracks movement).' },
|
|
381
|
+
monitor: { type: 'string', description: "Target a monitor (ignores cursor): index or 'primary'." },
|
|
382
|
+
boxW: { type: 'number', description: 'Cursor-mode box width.' },
|
|
383
|
+
boxH: { type: 'number', description: 'Cursor-mode box height.' },
|
|
384
|
+
scale: { type: 'number', description: 'Upscale factor when includeImage. When set, overrides detail.' },
|
|
385
|
+
maxSide: { type: 'number', description: 'Upper bound on the output longest side in px when includeImage. When set, overrides detail.' },
|
|
386
|
+
changeThreshold: { type: 'number', description: 'Change-detection threshold % (default 2).' },
|
|
387
|
+
watchId: { type: 'string', description: 'Watch-session id (default "default"). The previous state is remembered under this id.' },
|
|
388
|
+
reset: { type: 'boolean', description: 'If true, discard the previous state and start a new baseline (on watch start / target change).' },
|
|
389
|
+
detail: { type: 'string', description: "Resolution preset: 'gist'/'normal'/'text' (the saved image size when includeImage)." },
|
|
390
|
+
includeImage: { type: 'boolean', description: 'Default false = metadata only (changePct etc., token savings). If true, also save and return the changed/baseline frame as a PNG (path).' },
|
|
391
|
+
},
|
|
392
|
+
additionalProperties: false,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: 'beep',
|
|
397
|
+
description: 'Sound alert — call this to emit a beep when you have a message to show the user during watching (so they notice while looking at a game / another screen). Recommended to call right before printing the message. Windows-only. (A precursor to future voice TTS.)',
|
|
398
|
+
inputSchema: {
|
|
399
|
+
type: 'object',
|
|
400
|
+
properties: {
|
|
401
|
+
pattern: { type: 'string', description: "Beep pattern: 'info' (once) / 'warn' (twice) / 'urgent' (three times). Default info." },
|
|
402
|
+
count: { type: 'number', description: 'Repeat count (overrides pattern when set). 1-10.' },
|
|
403
|
+
frequency: { type: 'number', description: 'Pitch in Hz (37-32767).' },
|
|
404
|
+
durationMs: { type: 'number', description: 'Duration of one beep in ms.' },
|
|
405
|
+
},
|
|
406
|
+
additionalProperties: false,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
];
|
|
410
|
+
|
|
411
|
+
const server = new Server({ name: 'computer-use', version: '0.0.1-poc' }, { capabilities: { tools: {} } });
|
|
412
|
+
|
|
413
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
414
|
+
|
|
415
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
416
|
+
const { name, arguments: a = {} } = req.params;
|
|
417
|
+
const useWorker = plat === 'win32'; // the resident worker is Windows PowerShell backend only
|
|
418
|
+
let result;
|
|
419
|
+
let reqDir = null; // per-request temp dir for screenshots — deleted in finally on EVERY exit path (codex blocker: timeout/error left files behind)
|
|
420
|
+
// Clamp output-size knobs at the MCP boundary so a huge upscale can't blow up the response / PS render (codex #med).
|
|
421
|
+
if (a.scale != null) { const s = Number(a.scale); a.scale = Number.isFinite(s) ? Math.min(8, Math.max(0.1, s)) : undefined; }
|
|
422
|
+
if (a.maxSide != null) { const m = Number(a.maxSide); a.maxSide = Number.isFinite(m) ? Math.min(4096, Math.max(16, Math.floor(m))) : undefined; }
|
|
423
|
+
try {
|
|
424
|
+
if (name === 'probe') {
|
|
425
|
+
result = useWorker ? await viaWorker('probe', {}) : runBackend('probe');
|
|
426
|
+
} else if (name === 'read_ui') {
|
|
427
|
+
if (useWorker) {
|
|
428
|
+
const wa = {};
|
|
429
|
+
if (a.window) wa.windowMatch = String(a.window);
|
|
430
|
+
if (a.target) wa.target = String(a.target);
|
|
431
|
+
result = await viaWorker('read_ui', wa);
|
|
432
|
+
} else {
|
|
433
|
+
const args = [];
|
|
434
|
+
if (a.window) args.push('-WindowMatch', String(a.window));
|
|
435
|
+
if (a.target) args.push('-Target', String(a.target));
|
|
436
|
+
result = runBackend('read', args);
|
|
437
|
+
}
|
|
438
|
+
} else if (name === 'capture_screen') {
|
|
439
|
+
reqDir = mkdtempSync(join(tmpdir(), 'vortex-cu-'));
|
|
440
|
+
if (useWorker) {
|
|
441
|
+
const wa = { outDir: reqDir };
|
|
442
|
+
if (a.region) wa.region = `${a.region.x},${a.region.y},${a.region.w},${a.region.h}`;
|
|
443
|
+
if (a.window) wa.windowMatch = String(a.window);
|
|
444
|
+
if (a.monitor != null) wa.monitor = String(a.monitor);
|
|
445
|
+
if (a.boxW) wa.boxW = a.boxW;
|
|
446
|
+
if (a.boxH) wa.boxH = a.boxH;
|
|
447
|
+
if (a.detail) wa.detail = String(a.detail);
|
|
448
|
+
if (a.scale != null) wa.scale = a.scale;
|
|
449
|
+
if (a.maxSide != null) wa.maxSide = a.maxSide;
|
|
450
|
+
result = await viaWorker('capture', wa);
|
|
451
|
+
} else {
|
|
452
|
+
const args = ['-OutDir', reqDir];
|
|
453
|
+
if (a.boxW) args.push('-BoxW', String(a.boxW));
|
|
454
|
+
if (a.boxH) args.push('-BoxH', String(a.boxH));
|
|
455
|
+
result = runBackend('capture', args);
|
|
456
|
+
}
|
|
457
|
+
} else if (name === 'watch_capture') {
|
|
458
|
+
// watch isn't run on the resident worker (avoids blocking other calls with a long occupation, codex #1) -> always per-call spawn.
|
|
459
|
+
if (plat !== 'win32') {
|
|
460
|
+
result = { payload: { error: 'watch_capture is currently Windows-only', platform: plat }, isError: true };
|
|
461
|
+
} else {
|
|
462
|
+
// Hard caps so one watch call can't run unbounded; async runner so it never blocks the server (codex #high).
|
|
463
|
+
reqDir = mkdtempSync(join(tmpdir(), 'vortex-cu-'));
|
|
464
|
+
const MAX_FRAMES = 60, MIN_INTERVAL = 100, MAX_INTERVAL = 10000, MAX_WATCH_MS = 180000;
|
|
465
|
+
const reqFrames = Number(a.frames), reqInterval = Number(a.intervalMs);
|
|
466
|
+
const frames = Math.min(MAX_FRAMES, Math.max(1, Number.isFinite(reqFrames) && reqFrames > 0 ? Math.floor(reqFrames) : 2));
|
|
467
|
+
const intervalMs = Math.min(MAX_INTERVAL, Math.max(MIN_INTERVAL, Number.isFinite(reqInterval) && reqInterval > 0 ? Math.floor(reqInterval) : 1000));
|
|
468
|
+
const args = ['-WatchFrames', String(frames), '-IntervalMs', String(intervalMs), '-OutDir', reqDir];
|
|
469
|
+
if (a.changeOnly) args.push('-ChangeOnly');
|
|
470
|
+
if (a.changeThreshold != null) args.push('-ChangeThreshold', String(a.changeThreshold));
|
|
471
|
+
if (a.region) args.push('-Region', `${a.region.x},${a.region.y},${a.region.w},${a.region.h}`);
|
|
472
|
+
if (a.window) args.push('-WindowMatch', String(a.window));
|
|
473
|
+
if (a.monitor != null) args.push('-Monitor', String(a.monitor));
|
|
474
|
+
if (a.boxW) args.push('-BoxW', String(a.boxW));
|
|
475
|
+
if (a.boxH) args.push('-BoxH', String(a.boxH));
|
|
476
|
+
if (a.detail) args.push('-Detail', String(a.detail));
|
|
477
|
+
if (a.scale != null) args.push('-Scale', String(a.scale));
|
|
478
|
+
if (a.maxSide != null) args.push('-MaxSide', String(a.maxSide));
|
|
479
|
+
const timeoutMs = Math.min(MAX_WATCH_MS, frames * intervalMs + 15000);
|
|
480
|
+
result = await runBackendAsync('capture', args, timeoutMs, reqDir);
|
|
481
|
+
if (result.cleanupOwned) reqDir = null; // runBackendAsync deferred cleanup (timeout/cap fallback) — don't race it in finally
|
|
482
|
+
}
|
|
483
|
+
} else if (name === 'poll_change') {
|
|
484
|
+
// async watch primitive — a fast single shot, so it goes through the worker (which also holds the previous state). win32 only.
|
|
485
|
+
if (plat !== 'win32') {
|
|
486
|
+
result = { payload: { error: 'poll_change is currently Windows-only', platform: plat }, isError: true };
|
|
487
|
+
} else {
|
|
488
|
+
const wa = {};
|
|
489
|
+
if (a.includeImage) { reqDir = mkdtempSync(join(tmpdir(), 'vortex-cu-')); wa.outDir = reqDir; wa.includeImage = true; }
|
|
490
|
+
if (a.region) wa.region = `${a.region.x},${a.region.y},${a.region.w},${a.region.h}`;
|
|
491
|
+
if (a.window) wa.windowMatch = String(a.window);
|
|
492
|
+
if (a.monitor != null) wa.monitor = String(a.monitor);
|
|
493
|
+
if (a.boxW) wa.boxW = a.boxW;
|
|
494
|
+
if (a.boxH) wa.boxH = a.boxH;
|
|
495
|
+
if (a.scale != null) wa.scale = a.scale;
|
|
496
|
+
if (a.maxSide != null) wa.maxSide = a.maxSide;
|
|
497
|
+
if (a.detail) wa.detail = String(a.detail);
|
|
498
|
+
if (a.changeThreshold != null) wa.changeThreshold = a.changeThreshold;
|
|
499
|
+
const wid = a.watchId ? String(a.watchId) : 'default';
|
|
500
|
+
if (a.watchId) wa.watchId = wid;
|
|
501
|
+
if (a.reset) wa.reset = true;
|
|
502
|
+
const seenBefore = pollSeen.has(wid);
|
|
503
|
+
result = await viaWorker('poll_change', wa);
|
|
504
|
+
// Record the watchId only after a SUCCESSFUL poll (payload carries a boolean baseline) — a failed first call must
|
|
505
|
+
// not make the next valid baseline look like a reset; bound the set so attacker-chosen watchIds can't grow it forever. codex #low.
|
|
506
|
+
if (result && result.payload && typeof result.payload.baseline === 'boolean') {
|
|
507
|
+
if (pollSeen.size > 1000) pollSeen.clear();
|
|
508
|
+
pollSeen.add(wid);
|
|
509
|
+
}
|
|
510
|
+
// A non-reset follow-up that comes back baseline=true means the worker lost its state (restart/idle-dispose/crash).
|
|
511
|
+
// Surface it as a reset so the agent treats it as a fresh baseline — NOT as "no change" (a real change in the gap would otherwise be silently missed). codex #high.
|
|
512
|
+
if (!a.reset && seenBefore && result && result.payload && result.payload.baseline === true) {
|
|
513
|
+
result.payload.stateReset = true;
|
|
514
|
+
result.payload.warning = 'watch state was lost (worker restarted/idle-disposed) — this is a fresh baseline, not a "no change" result; a change during the gap may have been missed.';
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} else if (name === 'beep') {
|
|
518
|
+
if (plat !== 'win32') {
|
|
519
|
+
result = { payload: { error: 'beep is currently Windows-only', platform: plat }, isError: true };
|
|
520
|
+
} else {
|
|
521
|
+
const wa = {};
|
|
522
|
+
if (a.pattern) wa.pattern = String(a.pattern);
|
|
523
|
+
if (a.count != null) wa.count = a.count;
|
|
524
|
+
if (a.frequency != null) wa.frequency = a.frequency;
|
|
525
|
+
if (a.durationMs != null) wa.durationMs = a.durationMs;
|
|
526
|
+
result = await viaWorker('beep', wa);
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true };
|
|
530
|
+
}
|
|
531
|
+
const imageItems = materializeImages(result.payload);
|
|
532
|
+
auditLog(name, result.payload, imageItems); // metadata/HMAC only — original image and text are not stored (§8)
|
|
533
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.payload, null, 2) }, ...imageItems], isError: result.isError };
|
|
534
|
+
} finally {
|
|
535
|
+
if (reqDir) { try { rmSync(reqDir, { recursive: true, force: true }); } catch {} }
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Clean up the worker on server shutdown (it would also auto-terminate via stdin EOF when the parent dies, but do it explicitly).
|
|
540
|
+
process.on('exit', () => workerMgr.dispose());
|
|
541
|
+
process.on('SIGINT', () => process.exit(0));
|
|
542
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
543
|
+
|
|
544
|
+
const transport = new StdioServerTransport();
|
|
545
|
+
await server.connect(transport);
|
|
546
|
+
process.stderr.write(`[computer-use MCP] ready on stdio (worker=${plat === 'win32' ? 'on' : 'off'}; tools: probe, read_ui, capture_screen, watch_capture, poll_change, beep)\n`);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# computer-use — point-to-ask / capture (throwaway PoC) — CLI adapter over the shared logic in lib.ps1.
|
|
2
|
+
# Target (priority): Region > WindowMatch > Monitor > (default) cursor. Includes watch (-WatchFrames>1). Logic lives in lib.ps1::Invoke-AxCapture.
|
|
3
|
+
param(
|
|
4
|
+
[int]$BoxW = 600,
|
|
5
|
+
[int]$BoxH = 400,
|
|
6
|
+
[double]$Scale = 0,
|
|
7
|
+
[int]$MaxSide = 0,
|
|
8
|
+
[long]$MaxPixels = 40000000,
|
|
9
|
+
[string]$Detail = 'normal',
|
|
10
|
+
[string]$Region = '',
|
|
11
|
+
[string]$WindowMatch = '',
|
|
12
|
+
[string]$Monitor = '',
|
|
13
|
+
[int]$WatchFrames = 1,
|
|
14
|
+
[int]$IntervalMs = 1000,
|
|
15
|
+
[switch]$ChangeOnly,
|
|
16
|
+
[double]$ChangeThreshold = 2.0,
|
|
17
|
+
[string]$OutDir = (Join-Path $env:TEMP 'vortex-ax-poc')
|
|
18
|
+
)
|
|
19
|
+
$ErrorActionPreference = 'Stop'
|
|
20
|
+
. (Join-Path $PSScriptRoot 'lib.ps1')
|
|
21
|
+
Initialize-AxEnv
|
|
22
|
+
$params = @{
|
|
23
|
+
BoxW = $BoxW; BoxH = $BoxH; Scale = $Scale; MaxSide = $MaxSide; MaxPixels = $MaxPixels; Detail = $Detail
|
|
24
|
+
Region = $Region; WindowMatch = $WindowMatch; Monitor = $Monitor
|
|
25
|
+
WatchFrames = $WatchFrames; IntervalMs = $IntervalMs; ChangeThreshold = $ChangeThreshold; OutDir = $OutDir
|
|
26
|
+
}
|
|
27
|
+
if ($ChangeOnly) { $params.ChangeOnly = $true }
|
|
28
|
+
Invoke-AxCapture @params | ConvertTo-Json -Depth 6
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# computer-use — P0.5 probe (throwaway PoC) — CLI adapter over the shared logic in lib.ps1.
|
|
2
|
+
# Output = a single JSON blob. Logic lives in lib.ps1::Get-AxProbe.
|
|
3
|
+
$ErrorActionPreference = 'Stop'
|
|
4
|
+
. (Join-Path $PSScriptRoot 'lib.ps1')
|
|
5
|
+
Initialize-AxEnv
|
|
6
|
+
Get-AxProbe | ConvertTo-Json -Depth 6
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# computer-use — P1 read_ui (throwaway PoC) — CLI adapter over the shared logic in lib.ps1.
|
|
2
|
+
# Active/targeted window UIA tree (elements, roles, coordinates, id) + TextPattern body text -> JSON, zero images. Logic lives in lib.ps1::Get-AxReadUi.
|
|
3
|
+
param(
|
|
4
|
+
[int]$MaxDepth = 5,
|
|
5
|
+
[int]$MaxElements = 70,
|
|
6
|
+
[int]$TextCap = 1500,
|
|
7
|
+
[ValidateSet('foreground','cursor')] [string]$Target = 'foreground',
|
|
8
|
+
[string]$WindowMatch = ''
|
|
9
|
+
)
|
|
10
|
+
$ErrorActionPreference = 'Stop'
|
|
11
|
+
. (Join-Path $PSScriptRoot 'lib.ps1')
|
|
12
|
+
Initialize-AxEnv
|
|
13
|
+
Get-AxReadUi -MaxDepth $MaxDepth -MaxElements $MaxElements -TextCap $TextCap -Target $Target -WindowMatch $WindowMatch | ConvertTo-Json -Depth 12
|