@aryacode/dsh-plugin-arya-computer-use 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,66 @@
1
+ // MCP entry point for the web lane.
2
+ //
3
+ // DSH launches this file instead of agent-browser itself so the browser lane can
4
+ // follow the plugin's settings (tool surface, Chrome profile, session, CDP
5
+ // endpoint, headed window) without editing the Cordis patch by hand. The real
6
+ // server's stdio is inherited, so this process is a transparent pipe: stdin and
7
+ // stdout carry the MCP protocol untouched, and settings changes are picked up on
8
+ // the next MCP restart.
9
+
10
+ import { spawn } from 'node:child_process';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { createRequire } from 'node:module';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const BIN = require.resolve('agent-browser/bin/agent-browser.js');
18
+
19
+ function controlDir() {
20
+ const local = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
21
+ return join(local, 'dsh-plugin-arya-desktop', 'control');
22
+ }
23
+
24
+ function readSettings() {
25
+ const file = join(controlDir(), 'settings.json');
26
+ if (!existsSync(file)) return {};
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
29
+ return parsed && typeof parsed === 'object' ? parsed : {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ const text = (value) => (typeof value === 'string' ? value.trim() : '');
36
+
37
+ export function browserArgs(settings = {}) {
38
+ const args = [BIN, 'mcp'];
39
+ const tools = text(settings.browserTools);
40
+ if (tools) args.push('--tools', tools);
41
+ const cdp = text(settings.browserCdp);
42
+ const profile = text(settings.browserProfile);
43
+ const session = text(settings.browserSession);
44
+ const state = text(settings.browserStateFile);
45
+ if (cdp) args.push('--cdp', cdp);
46
+ if (profile) args.push('--profile', profile);
47
+ if (session) args.push('--session', session);
48
+ if (state) args.push('--state', state);
49
+ if (settings.browserHeaded === true) args.push('--headed');
50
+ return args;
51
+ }
52
+
53
+ function main() {
54
+ const args = browserArgs(readSettings());
55
+ const child = spawn(process.execPath, args, { stdio: 'inherit', windowsHide: true });
56
+ child.on('error', (error) => {
57
+ process.stderr.write(`arya browser lane: could not start agent-browser: ${error.message}\n`);
58
+ process.exit(1);
59
+ });
60
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
61
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
62
+ process.on(signal, () => { try { child.kill(signal); } catch { /* already gone */ } });
63
+ }
64
+ }
65
+
66
+ if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/').split('/').pop())) main();
@@ -0,0 +1,87 @@
1
+ // Settings for dsh-arya-computer-use. Derived from dsh-computer-use-win (MIT),
2
+ // trimmed to the takeover/indicator fields this plugin uses, plus behavior
3
+ // switches for the adapter (headedDefault, overlayDefault, driverLockEnabled).
4
+
5
+ export const DEFAULT_SETTINGS = Object.freeze({
6
+ progressOverlayEnabled: true, progressOverlayMode: 'source',
7
+ progressOverlayOpacity: 60, progressOverlayWidth: 360, progressOverlayHeight: 240, progressOverlayPosition: 'top-right', progressOverlayX: 80, progressOverlayY: 80, progressOverlayLocked: true, progressLockShortcut: '', progressPreviewToken: 0,
8
+ indicatorEnabled: true, cursorIndicatorEnabled: true, remoteIndicatorVisible: true, indicatorText: 'Arya is controlling your computer', pointerLabel: 'Arya',
9
+ indicatorPosition: 'top', indicatorIdleMs: 800, stopShortcut: '',
10
+ resumeToken: 0, previewToken: 0,
11
+ headedDefault: false, overlayDefault: true, driverLockEnabled: true, toolPreset: 'lite',
12
+ browserTools: 'core', browserProfile: '', browserSession: '', browserStateFile: '', browserCdp: '', browserHeaded: false,
13
+ });
14
+
15
+ export const STOP_SHORTCUT_PATTERN = /^(?:(?:Ctrl\+)?(?:Alt\+)?(?:Shift\+)?(?:Win\+)?(?:[A-Z0-9]|F(?:[1-9]|1[0-9]|2[0-4])|Space|Enter|Tab|Backspace|Delete|Insert|Home|End|PageUp|PageDown|Up|Down|Left|Right|Pause|CapsLock|NumLock|ScrollLock))?$/;
16
+
17
+ export function normalizeStopShortcut(value) {
18
+ if (typeof value !== 'string') throw new Error('stopShortcut must be a string');
19
+ if (!value) return '';
20
+ const parts = value.split('+'), key = parts.pop();
21
+ const modifiers = ['Ctrl', 'Alt', 'Shift', 'Win'];
22
+ if (parts.some(part => !modifiers.includes(part)) || new Set(parts).size !== parts.length ||
23
+ !/^(?:[A-Z0-9]|F(?:[1-9]|1[0-9]|2[0-4])|Space|Enter|Tab|Backspace|Delete|Insert|Home|End|PageUp|PageDown|Up|Down|Left|Right|Pause|CapsLock|NumLock|ScrollLock)$/.test(key)) {
24
+ throw new Error('stopShortcut must be a supported key with optional Ctrl, Alt, Shift, Win modifiers; Esc is always reserved');
25
+ }
26
+ return [...modifiers.filter(part => parts.includes(part)), key].join('+');
27
+ }
28
+
29
+ // A saved value this version does not offer (chosen in another version, or
30
+ // edited by hand) must not keep the plugin from loading: DSH refuses the whole
31
+ // entry when one value fails, and every tool then disappears with no visible
32
+ // error (#39). Such choices fall back to their defaults; each change is
33
+ // returned so it can be reported. Values typed on the settings page (shortcut,
34
+ // text, numbers) stay strict, so a mistyped one is still refused when saved.
35
+ const SETTING_CHOICES = { indicatorPosition: ['top', 'bottom'] };
36
+ const SETTING_SWITCHES = ['progressOverlayEnabled', 'indicatorEnabled', 'cursorIndicatorEnabled', 'remoteIndicatorVisible', 'progressOverlayLocked', 'headedDefault', 'overlayDefault', 'driverLockEnabled', 'browserHeaded'];
37
+ SETTING_CHOICES.progressOverlayPosition = ['top-right','top-left','bottom-right','bottom-left','custom'];
38
+ SETTING_CHOICES.progressOverlayMode = ['source','summary'];
39
+ SETTING_CHOICES.browserTools = ['core', 'network', 'state', 'debug', 'tabs', 'react', 'mobile', 'all'];
40
+ SETTING_CHOICES.toolPreset = ['lite', 'lazy'];
41
+ export function repairStaleSettings(input) {
42
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return { value: input, repaired: [] };
43
+ const value = { ...input }, repaired = [];
44
+ const read = key => typeof value[key]?.get === 'function' ? value[key].get() : value[key];
45
+ const reset = key => { repaired.push(`${key}: ${JSON.stringify(read(key))}`); delete value[key]; };
46
+ for (const [key, allowed] of Object.entries(SETTING_CHOICES)) if (read(key) != null && !allowed.includes(read(key))) reset(key);
47
+ for (const key of SETTING_SWITCHES) if (read(key) != null && typeof read(key) !== 'boolean') reset(key);
48
+ return { value, repaired };
49
+ }
50
+
51
+ export function normalizeSettings(input = {}) {
52
+ const value = { ...DEFAULT_SETTINGS, ...Object.fromEntries(Object.entries(input).map(([key,value])=>[key,typeof value?.get === 'function' ? value.get() : value])) };
53
+ for (const key of SETTING_SWITCHES) {
54
+ if (typeof value[key] !== 'boolean') throw new Error(`${key} must be boolean`);
55
+ }
56
+ if (typeof value.indicatorText !== 'string' || !value.indicatorText.trim() || value.indicatorText.length > 120 || /[\r\n\x00-\x1f]/.test(value.indicatorText)) {
57
+ throw new Error('indicatorText must be one line of 1–120 characters');
58
+ }
59
+ if (typeof value.pointerLabel !== 'string' || !value.pointerLabel.trim() || value.pointerLabel.trim().length > 16 || /[\r\n\x00-\x1f]/.test(value.pointerLabel)) {
60
+ throw new Error('pointerLabel must be one line of 1–16 characters');
61
+ }
62
+ value.pointerLabel = value.pointerLabel.trim();
63
+ if (!['top', 'bottom'].includes(value.indicatorPosition)) throw new Error('indicatorPosition must be top or bottom');
64
+ if (!Number.isInteger(value.indicatorIdleMs) || value.indicatorIdleMs < 200 || value.indicatorIdleMs > 3000) throw new Error('indicatorIdleMs must be 200–3000');
65
+ value.stopShortcut = normalizeStopShortcut(value.stopShortcut);
66
+ value.progressLockShortcut = normalizeStopShortcut(value.progressLockShortcut);
67
+ if (value.progressLockShortcut && value.progressLockShortcut === value.stopShortcut) throw new Error('progressLockShortcut must differ from the stop shortcut');
68
+ for (const [key,min,max] of [['progressOverlayOpacity',20,100],['progressOverlayWidth',240,1600],['progressOverlayHeight',160,1600],['progressOverlayX',-32768,32767],['progressOverlayY',-32768,32767]]) {
69
+ if (!Number.isInteger(value[key]) || value[key] < min || value[key] > max) throw new Error(`${key} must be an integer between ${min} and ${max}`);
70
+ }
71
+ if (!SETTING_CHOICES.progressOverlayPosition.includes(value.progressOverlayPosition)) throw new Error('Invalid progressOverlayPosition');
72
+ if (!SETTING_CHOICES.progressOverlayMode.includes(value.progressOverlayMode)) throw new Error('Invalid progressOverlayMode');
73
+ if (!SETTING_CHOICES.toolPreset.includes(value.toolPreset)) throw new Error('toolPreset must be one of ' + SETTING_CHOICES.toolPreset.join(', '));
74
+ if (!SETTING_CHOICES.browserTools.includes(value.browserTools)) throw new Error('browserTools must be one of ' + SETTING_CHOICES.browserTools.join(', '));
75
+ for (const key of ['browserProfile', 'browserSession', 'browserStateFile', 'browserCdp']) {
76
+ if (typeof value[key] !== 'string') throw new Error(`${key} must be a string`);
77
+ if (/[\r\n\x00-\x1f]/.test(value[key])) throw new Error(`${key} must be one line`);
78
+ value[key] = value[key].trim();
79
+ }
80
+ if (value.browserSession && !/^[\p{L}\p{N}_-]+$/u.test(value.browserSession)) throw new Error('browserSession may contain letters, numbers, hyphens and underscores');
81
+ if (value.browserCdp && !/^(?:\d{1,5}|https?:\/\/\S+|wss?:\/\/\S+)$/.test(value.browserCdp)) throw new Error('browserCdp must be a port, an http(s):// URL or a ws:// URL');
82
+ if (value.browserStateFile && !/^(?:[A-Za-z]:[\\/]|\/)/.test(value.browserStateFile)) throw new Error('browserStateFile must be an absolute path');
83
+ for (const key of ['resumeToken', 'previewToken', 'progressPreviewToken']) {
84
+ if (!Number.isSafeInteger(value[key]) || value[key] < 0) throw new Error(`${key} must be a nonnegative integer`);
85
+ }
86
+ return Object.fromEntries(Object.keys(DEFAULT_SETTINGS).map(key => [key, value[key]]));
87
+ }
@@ -0,0 +1,253 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { DEFAULT_SETTINGS, normalizeSettings } from './settings.mjs';
7
+
8
+ export const STOPPED = 'COMPUTER_USE_STOPPED_BY_USER: The user stopped this operation series with Esc or their custom shortcut. Do not retry desktop input in this turn. A new user turn, including a request to continue, can use the desktop automatically; no settings change is required.';
9
+ const helperScript = fileURLToPath(new URL('../scripts/windows-takeover.ps1', import.meta.url));
10
+ // What "Open overlay to adjust" shows: the same layout as a real run.
11
+ export const PREVIEW_PROGRESS = Object.freeze({ step: 2, status: 'acting', tool: 'scroll', action: 'scroll the page', steps: [
12
+ { step: 1, actions: [{ label: 'open a new tab', state: 'done' }], reasoning: 'This is where DSH streams its thinking text, matching what you see in the chat.', reply: '' },
13
+ { step: 2, actions: [{ label: 'scroll the page', state: 'running' }], reasoning: 'Long pages can be scrolled; once at the bottom it keeps following new output.', reply: '' }] });
14
+
15
+ // Windows refuses to replace a file that another process holds open without
16
+ // delete sharing (a reader, an antivirus scan) for a moment, with EPERM, EACCES
17
+ // or EBUSY. Retry the replace briefly, as graceful-fs does for rename on win32.
18
+ const BUSY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
19
+ function pause(ms) {
20
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
21
+ catch { const end = Date.now() + ms; while (Date.now() < end) { } }
22
+ }
23
+ function replaceFile(from, to) {
24
+ for (let delay = 10, waited = 0; ; delay = Math.min(delay * 2, 100)) {
25
+ try { renameSync(from, to); return; }
26
+ catch (error) {
27
+ if (!BUSY_CODES.has(error?.code) || waited >= 1000) throw error;
28
+ pause(delay); waited += delay;
29
+ }
30
+ }
31
+ }
32
+
33
+ export function atomicJson(file, value) {
34
+ if (!file) return;
35
+ mkdirSync(path.dirname(file), { recursive: true });
36
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
37
+ try {
38
+ writeFileSync(temporary, JSON.stringify(value) + '\n', 'utf8');
39
+ replaceFile(temporary, file);
40
+ } catch (error) {
41
+ // This temporary file belongs to this failed write and has no further use.
42
+ try { unlinkSync(temporary); } catch { }
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ export class TakeoverControl {
48
+ constructor({ powershell = 'powershell.exe', onStop = () => {}, configFile = process.env.ARYA_CONFIG_FILE,
49
+ stateFile = process.env.ARYA_STATE_FILE, activityFile=process.env.ARYA_ACTIVITY_FILE,targetFile=process.env.ARYA_TARGET_FILE,testMode = false, config = {} } = {}) {
50
+ this.powershell = powershell; this.onStop = onStop; this.configFile = configFile; this.stateFile = stateFile;
51
+ this.testMode = testMode; this.config = normalizeSettings(config); this.active = 0; this.shown = 0; this.paused = false;
52
+ this.layoutFile=process.env.ARYA_LAYOUT_FILE;
53
+ this.targetFile=targetFile;
54
+ this.progressPreviewToken=this.config.progressPreviewToken;
55
+ this.activityFile=activityFile;this.seriesHeld=false;this.seriesIndicator=false;this.seriesKey=null;this.stoppedSeriesKey=null;this.blockedSeries=new Map();
56
+ this.eventName = `Local\\DSH-ARYA-${randomUUID()}`; this.waiters = new Map(); this.nextId = 0;
57
+ this.resumeToken = this.config.resumeToken; this.previewToken = this.config.previewToken; this.pauseMessage = STOPPED;
58
+ this.disabledForTest = process.env.NODE_ENV === 'test' && process.env.ARYA_TEST_NO_TAKEOVER === '1';
59
+ try {
60
+ const saved = JSON.parse(readFileSync(this.stateFile, 'utf8'));
61
+ this.paused = saved.paused === true;
62
+ this.pauseMessage = saved.pauseMessage ?? STOPPED;
63
+ this.resumeToken = saved.resumeToken ?? this.resumeToken;
64
+ this.stoppedSeriesKey=saved.stoppedSeriesKey??null;
65
+ this.blockedSeries=new Map(Object.entries(saved.blockedSeries??(this.stoppedSeriesKey?{[this.stoppedSeriesKey]:this.pauseMessage}:{})));
66
+ } catch (error) { if (this.stateFile && error.code !== 'ENOENT') throw error; }
67
+ if (configFile||activityFile) {
68
+ this.poll = setInterval(() => { this.sync().catch(error => this.fail(error)); }, 250);
69
+ this.poll.unref();
70
+ }
71
+ }
72
+ persist(reason) {
73
+ atomicJson(this.stateFile, { paused: this.paused, active: this.active, seriesActive:this.seriesHeld,reason: reason ?? null,
74
+ resumeToken: this.resumeToken,stoppedSeriesKey:this.stoppedSeriesKey,blockedSeries:Object.fromEntries(this.blockedSeries),pauseMessage: this.paused ? this.pauseMessage : null, updatedAt: new Date().toISOString() });
75
+ }
76
+ // What the overlay should frame: the app window being driven, and where the
77
+ // harness acted (its ghost cursor). Written by the MCP adapter after each call.
78
+ readTarget() {
79
+ let raw = null;
80
+ try { raw = JSON.parse(readFileSync(this.targetFile, 'utf8')); } catch { raw = null; }
81
+ const window = raw?.window, pointer = raw?.pointer;
82
+ const rect = window && Number.isFinite(window.width) && Number.isFinite(window.height) && window.width > 0 && window.height > 0
83
+ ? { left: Math.round(window.left), top: Math.round(window.top), width: Math.round(window.width), height: Math.round(window.height) }
84
+ : null;
85
+ const point = pointer && Number.isFinite(pointer.x) && Number.isFinite(pointer.y)
86
+ ? { x: Math.round(pointer.x), y: Math.round(pointer.y) }
87
+ : null;
88
+ return { target: rect, pointer: point };
89
+ }
90
+ async resumeOperation(clearScope=true){
91
+ if(this.child)await this.command('resume');
92
+ this.paused=false;if(clearScope)this.stoppedSeriesKey=null;
93
+ this.persist('new-operation');
94
+ }
95
+ sync() {
96
+ // Each caller needs a read after its own config/turn update. Reusing an
97
+ // in-flight read can otherwise apply the previous turn's stopped state.
98
+ const pending=(this.syncing??Promise.resolve()).catch(()=>{}).then(()=>this.syncInner());
99
+ this.syncing=pending;
100
+ return pending.finally(()=>{if(this.syncing===pending)this.syncing=null;});
101
+ }
102
+ async syncInner() {
103
+ if (this.closed) return;
104
+ // One synchronous read each: an async read keeps the file open across
105
+ // event-loop turns, and a writer in the same process (tests run the host's
106
+ // publisher here) retrying its replace synchronously would wait it out.
107
+ if (this.configFile) this.config = normalizeSettings(JSON.parse(readFileSync(this.configFile, 'utf8')));
108
+ if(this.activityFile){
109
+ const activity=JSON.parse(readFileSync(this.activityFile,'utf8'));
110
+ const held=activity.active===true&&Number.isFinite(activity.expiresAt)&&activity.expiresAt>Date.now();
111
+ const key=held?JSON.stringify([activity.sessionId,activity.turn]):this.seriesKey;
112
+ // A series of browser-lane calls is held for turn tracking but shows
113
+ // nothing; the indicator starts with the series' first desktop call.
114
+ const indicator=held&&activity.indicator!==false;
115
+ // After the turn ends the overlay keeps showing its final state for a
116
+ // short time; that holds no series, indicator or stop key.
117
+ const lingering=!held&&!!activity.progress&&Number.isFinite(activity.lingerUntil)&&activity.lingerUntil>Date.now();
118
+ const progress=JSON.stringify({enabled:this.config.progressOverlayEnabled&&(held||lingering),remoteIndicatorVisible:this.config.remoteIndicatorVisible,settings:this.config,progress:{...activity.progress,status:this.paused&&held?'stopped':activity.progress?.status??'waiting'}});
119
+ if(!this.disabledForTest&&this.sentProgress!==progress&&(this.child||(this.config.progressOverlayEnabled&&(held||lingering)))){
120
+ await this.command('progress',JSON.parse(progress));this.sentProgress=progress;
121
+ }
122
+ if(held&&!this.blockedSeries.has(key)&&this.paused&&this.active===0)await this.resumeOperation();
123
+ if(held!==this.seriesHeld||key!==this.seriesKey||indicator!==this.seriesIndicator){
124
+ this.seriesKey=key;
125
+ this.seriesHeld=held;this.seriesIndicator=indicator;
126
+ if(!this.paused&&!this.blockedSeries.has(key)&&!this.disabledForTest){
127
+ if(indicator)await this.command('begin',this.config);
128
+ else if(this.shown===0&&this.child)await this.command('end');
129
+ }
130
+ this.persist();
131
+ }
132
+ }
133
+ const presentation=JSON.stringify([this.config.indicatorEnabled,this.config.cursorIndicatorEnabled,this.config.remoteIndicatorVisible,this.config.indicatorText,this.config.pointerLabel,this.config.indicatorPosition,this.config.indicatorIdleMs,this.config.stopShortcut]);
134
+ if (this.child && this.sentPresentation!==presentation) {
135
+ await this.command(((this.seriesHeld&&this.seriesIndicator)||this.shown>0)&&!this.paused&&!this.blockedSeries.has(this.seriesKey)?'begin':'configure', this.config);
136
+ this.sentPresentation=presentation;
137
+ this.sentShortcut = this.config.stopShortcut; this.sentCursorIndicator = this.config.cursorIndicatorEnabled;this.sentRemoteVisible=this.config.remoteIndicatorVisible;
138
+ // A new overlay host paints nothing until it is told where to look.
139
+ this.sentTarget=null;
140
+ }
141
+ if (this.targetFile && this.child) {
142
+ const hint=this.readTarget();
143
+ if(hint!==this.sentTarget){await this.command('hint',hint);this.sentTarget=hint;}
144
+ }
145
+ if (this.config.resumeToken !== this.resumeToken && this.active === 0) {
146
+ if (this.child) await this.command('resume');
147
+ this.resumeToken = this.config.resumeToken; this.paused = false;this.stoppedSeriesKey=null;this.blockedSeries.clear(); this.persist('resumed-by-user');
148
+ if(this.seriesHeld&&this.seriesIndicator&&!this.disabledForTest)await this.command('begin',this.config);
149
+ }
150
+ if (this.config.previewToken !== this.previewToken) {
151
+ this.previewToken = this.config.previewToken;
152
+ if(this.paused&&this.active===0&&!this.seriesHeld)await this.resumeOperation(false);
153
+ if (!this.paused && this.active === 0 && !this.disabledForTest) await this.command('preview', this.config);
154
+ }
155
+ if(this.config.progressPreviewToken!==this.progressPreviewToken){
156
+ this.progressPreviewToken=this.config.progressPreviewToken;
157
+ if(!this.disabledForTest&&this.progressPreviewToken>0)await this.command('progress',{enabled:true,preview:true,settings:this.config,remoteIndicatorVisible:this.config.remoteIndicatorVisible,progress:PREVIEW_PROGRESS});
158
+ }
159
+ }
160
+ // A quiet lease (browser-lane calls) checks the stopped state and counts as an
161
+ // action in progress, but neither shows the indicator nor arms the stop keys.
162
+ async begin({ quiet = false } = {}) {
163
+ await this.sync();
164
+ if(this.seriesHeld&&this.blockedSeries.has(this.seriesKey))throw new Error(this.blockedSeries.get(this.seriesKey));
165
+ if(this.paused&&!this.activityFile&&this.active===0)await this.resumeOperation();
166
+ if (this.paused) throw new Error(this.pauseMessage);
167
+ if (this.closed) throw new Error('COMPUTER_USE_STOPPING');
168
+ if (!quiet&&!this.disabledForTest&&(!this.seriesHeld||!this.seriesIndicator||!this.child)) {
169
+ try { await this.command('begin', this.config); this.sentShortcut = this.config.stopShortcut; this.sentCursorIndicator = this.config.cursorIndicatorEnabled;this.sentRemoteVisible=this.config.remoteIndicatorVisible; }
170
+ catch (error) { this.fail(error); throw error; }
171
+ }
172
+ if (this.paused) throw new Error(this.pauseMessage);
173
+ this.active++; if (!quiet) this.shown++; this.persist();
174
+ }
175
+ async end({ quiet = false } = {}) {
176
+ this.active = Math.max(0, this.active - 1); if (!quiet) this.shown = Math.max(0, this.shown - 1); this.persist(this.paused ? 'stopped-by-user' : null);
177
+ if (!quiet && this.shown === 0 && !(this.seriesHeld&&this.seriesIndicator)&&this.child && !this.paused) await this.command('end').catch(error => this.fail(error));
178
+ }
179
+ fail(error) {
180
+ if (this.closed || this.paused) return;
181
+ this.paused = true;
182
+ this.stoppedSeriesKey=this.seriesKey;
183
+ this.pauseMessage = error?.message === STOPPED ? STOPPED : `COMPUTER_USE_PAUSED: ${error?.message ?? error}. This operation series is paused. Do not retry in this turn; a new user turn can retry the desktop connection.`;
184
+ if(this.seriesKey!==null)this.blockedSeries.set(this.seriesKey,this.pauseMessage);
185
+ this.persist(String(error?.message ?? error));
186
+ // This also covers a control-file or presentation failure while input runs.
187
+ this.child?.stdin.write(JSON.stringify({command:'stop'})+'\n',()=>{});
188
+ this.onStop(new Error(this.pauseMessage));
189
+ }
190
+ async start() {
191
+ if (this.starting) return this.starting;
192
+ if (this.child) return;
193
+ this.starting = new Promise((resolve, reject) => {
194
+ let buffer = '', diagnostic = '', ready = false;
195
+ const args = ['-NoLogo', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', helperScript, '-CancelEvent', this.eventName];
196
+ if (this.testMode) args.push('-TestMode');
197
+ const child = this.child = spawn(this.powershell, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
198
+ const deadline = setTimeout(() => { reject(new Error('Takeover stop listener startup timed out')); child.kill(); }, 15000);
199
+ child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
200
+ child.stderr.on('data', chunk => { diagnostic = (diagnostic + chunk).slice(-6000); });
201
+ child.stdout.on('data', chunk => {
202
+ buffer += chunk;
203
+ for (;;) {
204
+ const at = buffer.indexOf('\n'); if (at < 0) break;
205
+ const line = buffer.slice(0, at).trim(); buffer = buffer.slice(at + 1); if (!line) continue;
206
+ let message; try { message = JSON.parse(line); } catch { continue; }
207
+ if (message.kind === 'ready') { ready = true; clearTimeout(deadline); resolve(); }
208
+ else if (message.kind === 'stopped') this.fail(new Error(STOPPED));
209
+ else if (message.kind === 'progress-layout') {
210
+ if(this.layoutFile)try{atomicJson(this.layoutFile,{id:randomUUID(),patch:message.patch,base:message.base});}catch(error){process.stderr.write('Overlay settings sync failed: '+error.message+'\n');}
211
+ }
212
+ else {
213
+ const pending = this.waiters.get(message.id); if (!pending) continue;
214
+ clearTimeout(pending.timer); this.waiters.delete(message.id);
215
+ message.ok ? pending.resolve(message) : pending.reject(new Error(message.error));
216
+ }
217
+ }
218
+ });
219
+ const died = error => {
220
+ clearTimeout(deadline);
221
+ if (this.child === child) this.child = null;
222
+ const failure = error ?? new Error(`Takeover stop listener exited${diagnostic ? ': ' + diagnostic : ''}`);
223
+ if (!ready) reject(failure);
224
+ for (const [id, pending] of this.waiters) { clearTimeout(pending.timer); pending.reject(failure); this.waiters.delete(id); }
225
+ if (!this.closed) this.fail(failure);
226
+ };
227
+ child.once('error', died); child.once('exit', () => died());
228
+ child.stdin.on('error', () => {});
229
+ }).finally(() => { this.starting = null; });
230
+ return this.starting;
231
+ }
232
+ async command(command, params = {}) {
233
+ if(this.closed&&command!=='close')throw Error('COMPUTER_USE_STOPPING');
234
+ await this.start();
235
+ const id = ++this.nextId;
236
+ return new Promise((resolve, reject) => {
237
+ const timer = setTimeout(() => { this.waiters.delete(id); reject(new Error(`Takeover ${command} timed out`)); }, 5000);
238
+ this.waiters.set(id, { resolve, reject, timer });
239
+ this.child.stdin.write(JSON.stringify({ ...params, id, command }) + '\n', error => {
240
+ if (error) { clearTimeout(timer); this.waiters.delete(id); reject(error); }
241
+ });
242
+ });
243
+ }
244
+ async close() {
245
+ this.closed = true; clearInterval(this.poll);
246
+ if (this.child) {
247
+ const child = this.child;
248
+ await this.command('close').catch(() => {});
249
+ child.stdin.end();
250
+ const force = setTimeout(() => { if (child.exitCode === null) child.kill(); }, 1000); force.unref();
251
+ }
252
+ }
253
+ }
package/package.json CHANGED
@@ -1,6 +1,90 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-plugin-arya-computer-use",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Computer use for DSH/AryaCode: agent-desktop UIA engine, agent-browser web lane, takeover UI with a ghost cursor",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./mcp/adapter-server.mjs": "./mcp/adapter-server.mjs",
11
+ "./mcp/settings.mjs": "./mcp/settings.mjs",
12
+ "./mcp/takeover.mjs": "./mcp/takeover.mjs",
13
+ "./mcp/activity.mjs": "./mcp/activity.mjs",
14
+ "./locale/*.json": "./locale/*.json",
15
+ "./icon": "./icon.svg"
16
+ },
17
+ "bin": {
18
+ "arya-computer-use-mcp": "./mcp/adapter-server.mjs"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "client.js",
23
+ "cordis.patch.yml",
24
+ "mcp/adapter-server.mjs",
25
+ "mcp/settings.mjs",
26
+ "mcp/takeover.mjs",
27
+ "mcp/activity.mjs",
28
+ "mcp/browser-launcher.mjs",
29
+ "scripts/windows-takeover.ps1",
30
+ "scripts/windows-takeover.cs",
31
+ "scripts/ocr.ps1",
32
+ "skills/arya-desktop/SKILL.md",
33
+ "locale/en.json",
34
+ "icon.svg",
35
+ "LICENSE",
36
+ "NOTICE.md",
37
+ "docs/PLAN.md"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18",
41
+ "dsh": ">=0.2.1-rc.3 <0.3.0-0"
42
+ },
43
+ "dependencies": {
44
+ "@deepseek-ai/schemastery": "^3.19.0",
45
+ "agent-browser": "0.41.2",
46
+ "agent-desktop": "1.2.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@deepseek-ai/dsh-mcp-client": ">=0.1.0-rc.2 || >=0.2.0-rc.2"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@deepseek-ai/dsh-mcp-client": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "dsh": {
57
+ "bundle": {
58
+ "patch": "./cordis.patch.yml"
59
+ },
60
+ "client": {
61
+ "platform": "web",
62
+ "inject": [
63
+ "@deepseek-ai/dsh-client-locale",
64
+ "@deepseek-ai/dsh-client-ui-settings"
65
+ ]
66
+ }
67
+ },
68
+ "repository": {
69
+ "type": "git",
70
+ "url": "git+https://github.com/KonnectSuite/dsh-arya-computer-use.git"
71
+ },
72
+ "homepage": "https://github.com/KonnectSuite/dsh-arya-computer-use#readme",
73
+ "bugs": {
74
+ "url": "https://github.com/KonnectSuite/dsh-arya-computer-use/issues"
75
+ },
76
+ "keywords": [
77
+ "dsh",
78
+ "dsh-plugin",
79
+ "computer-use",
80
+ "agent-desktop",
81
+ "uia",
82
+ "accessibility",
83
+ "agent-browser",
84
+ "windows"
85
+ ],
86
+ "author": "AryaCode",
87
+ "publishConfig": {
88
+ "access": "public"
89
+ }
90
+ }
@@ -0,0 +1,95 @@
1
+ <#
2
+ Arya Computer Use — OCR helper.
3
+
4
+ Reads text from a PNG via the WinRT Windows.Media.Ocr engine (the same
5
+ engine family wincu uses). Run as a standalone powershell.exe process:
6
+ FullLanguage, outside the DSH pwsh sandbox.
7
+
8
+ Output: one JSON object on stdout:
9
+ { ok, engine, width, height, lines: [ { text, x, y, width, height,
10
+ words: [ { text, x, y, width, height } ] } ] }
11
+ Coordinates are PNG pixels (screenshot pixels), not UIA refs.
12
+ #>
13
+ param(
14
+ [Parameter(Mandatory = $true)][string]$ImagePath
15
+ )
16
+ $ErrorActionPreference = 'Stop'
17
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
18
+
19
+ # Load the WinRT activation types.
20
+ $null = [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime]
21
+ $null = [Windows.Storage.FileAccessMode, Windows.Storage, ContentType = WindowsRuntime]
22
+ $null = [Windows.Storage.Streams.IRandomAccessStream, Windows.Storage.Streams, ContentType = WindowsRuntime]
23
+ $null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Graphics.Imaging, ContentType = WindowsRuntime]
24
+ $null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Graphics.Imaging, ContentType = WindowsRuntime]
25
+ $null = [Windows.Media.Ocr.OcrEngine, Windows.Media.Ocr, ContentType = WindowsRuntime]
26
+ $null = [Windows.Media.Ocr.OcrResult, Windows.Media.Ocr, ContentType = WindowsRuntime]
27
+
28
+ # WinRT async bridge: IAsyncOperation<T> arrives as a raw __ComObject (no
29
+ # instance AsTask, reflection can't see its interfaces), so pass the result
30
+ # type explicitly and invoke the generic static AsTask extension — the
31
+ # canonical WinRT-from-PowerShell pattern. Requires FullLanguage powershell.exe
32
+ # (the adapter spawns it directly, outside the DSH pwsh sandbox).
33
+ Add-Type -AssemblyName System.Runtime.WindowsRuntime
34
+ $asTaskGeneric = [System.WindowsRuntimeSystemExtensions].GetMethods() |
35
+ Where-Object { $_.Name -eq 'AsTask' -and $_.IsGenericMethod -and $_.GetParameters().Count -eq 1 } |
36
+ Select-Object -First 1
37
+
38
+ function Await($op, $resultType) {
39
+ $task = $asTaskGeneric.MakeGenericMethod($resultType).Invoke($null, @($op))
40
+ $null = $task.Wait(-1)
41
+ return $task.Result
42
+ }
43
+
44
+ function EmitJson($obj) {
45
+ [Console]::Out.WriteLine((ConvertTo-Json -InputObject $obj -Depth 6 -Compress))
46
+ }
47
+
48
+ if (-not (Test-Path -LiteralPath $ImagePath)) {
49
+ EmitJson @{ ok = $false; error = "image not found: $ImagePath" }
50
+ exit 1
51
+ }
52
+
53
+ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
54
+ if (-not $engine) {
55
+ EmitJson @{ ok = $false; error = 'no OCR engine for user profile languages' }
56
+ exit 1
57
+ }
58
+
59
+ $file = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($ImagePath)) ([Windows.Storage.StorageFile])
60
+ $stream = Await ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
61
+ $decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
62
+ $bitmap = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
63
+ $result = Await ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
64
+
65
+ $lines = @()
66
+ foreach ($line in $result.Lines) {
67
+ $words = @()
68
+ foreach ($word in $line.Words) {
69
+ $r = $word.BoundingRect
70
+ $words += @{
71
+ text = $word.Text
72
+ x = [math]::Round($r.X)
73
+ y = [math]::Round($r.Y)
74
+ width = [math]::Round($r.Width)
75
+ height = [math]::Round($r.Height)
76
+ }
77
+ }
78
+ $lb = $line.BoundingRect
79
+ $lines += @{
80
+ text = $line.Text
81
+ x = [math]::Round($lb.X)
82
+ y = [math]::Round($lb.Y)
83
+ width = [math]::Round($lb.Width)
84
+ height = [math]::Round($lb.Height)
85
+ words = $words
86
+ }
87
+ }
88
+
89
+ EmitJson @{
90
+ ok = $true
91
+ engine = $engine.RecognizerLanguage.LanguageTag
92
+ width = $bitmap.PixelWidth
93
+ height = $bitmap.PixelHeight
94
+ lines = $lines
95
+ }