@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.
package/index.js ADDED
@@ -0,0 +1,622 @@
1
+ // Arya Computer Use — Host plugin entry.
2
+ //
3
+ // Registers:
4
+ // - the arya-desktop workflow skill (provider, global + per-agent, rank 350;
5
+ // pattern derived from @axyatech/dsh-plugin-rea (MIT));
6
+ // - the settings store (settings.register('arya-computer-use', …, applies
7
+ // 'live'), derived from dsh-computer-use-win (MIT));
8
+ // - the takeover helper (scripts/windows-takeover.ps1: edge glow, banner,
9
+ // progress overlay, cursor indicator, global Esc kill) driven by
10
+ // TakeoverControl from the host-side activity file;
11
+ // - the driver lock (one conversation drives the desktop at a time —
12
+ // enforced in mcp/activity.mjs via the global tools/execute hook);
13
+ // - a loopback-only GET /plugins/arya/status for panels and diagnostics.
14
+ //
15
+ // The MCP adapter (mcp/adapter-server.mjs) runs as its own profile entry
16
+ // (mcp-arya-desktop); it polls control/settings.json for the behavior
17
+ // switches. See docs/PLAN.md §2 for the full design.
18
+
19
+ import z from '@deepseek-ai/schemastery';
20
+ import os from 'node:os';
21
+ import { readFile, readFileSync, readdirSync, statSync } from 'node:fs';
22
+ import { execFileSync } from 'node:child_process';
23
+ import { dirname, join } from 'node:path';
24
+ import { createRequire } from 'node:module';
25
+ import { fileURLToPath, pathToFileURL } from 'node:url';
26
+ import { DEFAULT_SETTINGS, normalizeSettings, STOP_SHORTCUT_PATTERN, repairStaleSettings } from './mcp/settings.mjs';
27
+ import { TakeoverControl, atomicJson } from './mcp/takeover.mjs';
28
+ import { installActivity } from './mcp/activity.mjs';
29
+
30
+ export const name = 'arya-computer-use';
31
+ export const inject = ['tools'];
32
+
33
+ const HERE = dirname(fileURLToPath(import.meta.url));
34
+ const SKILL_DIR = join(HERE, 'skills', 'arya-desktop');
35
+ const SKILL_FILE = join(SKILL_DIR, 'SKILL.md');
36
+ const PROVIDER_NAME = 'arya-computer-use';
37
+ const RANK = 350;
38
+ const STATUS_PATH = '/plugins/arya/status';
39
+ const PLUGIN_VERSION = (() => {
40
+ try { return JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')).version ?? '0.0.0'; }
41
+ catch { return '0.0.0'; }
42
+ })();
43
+
44
+ // The MCP servers are mounted in-process through the harness's own mcp-client
45
+ // plugin (the same one a patch row names), so this bundle carries no absolute
46
+ // paths and survives installation from a registry. Resolution order: an explicit
47
+ // override, the profile's own resolution, then the running harness install.
48
+ const HOST_DSH_ROOT = (() => {
49
+ try { return join(dirname(process.execPath), 'resources', 'app.asar', 'dsh'); }
50
+ catch { return null; }
51
+ })();
52
+
53
+ let mcpStatus = { mounted: false, servers: [], resolveErrors: [] };
54
+
55
+ // Every place a host's mcp-client copy can live: the profile that installed us
56
+ // (pnpm gives a registry install its peer link beside our own node_modules), the
57
+ // running harness image, and a dev override. First hit wins.
58
+ function mcpBridgeCandidates() {
59
+ const candidates = [];
60
+ if (process.env.ARYA_MCP_CLIENT_PATH) candidates.push(pathToFileURL(process.env.ARYA_MCP_CLIENT_PATH).href);
61
+ candidates.push('@deepseek-ai/dsh-mcp-client');
62
+ const suffix = join('node_modules', '@deepseek-ai', 'dsh-mcp-client', 'lib', 'index.js');
63
+ if (HOST_DSH_ROOT) candidates.push(pathToFileURL(join(HOST_DSH_ROOT, suffix)).href);
64
+ const home = os.homedir();
65
+ const roots = [join(HERE, suffix)];
66
+ try {
67
+ const profiles = join(home, '.aryacode', 'profiles');
68
+ for (const profile of readdirSync(profiles)) roots.push(join(profiles, profile, suffix));
69
+ } catch { /* no dev profiles on this host */ }
70
+ roots.push(join(home, '.aryacode', suffix));
71
+ for (const root of roots) candidates.push(pathToFileURL(root).href);
72
+ return candidates;
73
+ }
74
+
75
+ async function loadMcpBridge() {
76
+ const resolveErrors = [];
77
+ for (const specifier of mcpBridgeCandidates()) {
78
+ try {
79
+ const module = await import(specifier);
80
+ if (typeof module?.apply === 'function') return { bridge: module, resolveErrors };
81
+ resolveErrors.push(`${specifier}: no apply export`);
82
+ } catch (error) {
83
+ resolveErrors.push(`${specifier}: ${error?.code ?? error?.message ?? String(error)}`);
84
+ }
85
+ }
86
+ return { bridge: null, resolveErrors };
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Engine binaries.
91
+ //
92
+ // agent-desktop and agent-browser ship their native binary through a
93
+ // postinstall script. pnpm (and therefore the plugin manager) refuses to run
94
+ // dependency build scripts unless they are approved, so a market install can
95
+ // leave both engines without a binary. Rather than fail with an opaque spawn
96
+ // error much later, run the vendor's own postinstall once, ourselves, and
97
+ // report what happened in the status route.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const ENGINE_PACKAGES = ['agent-desktop', 'agent-browser'];
101
+
102
+ function engineRoot(pkg) {
103
+ try {
104
+ return dirname(createRequire(import.meta.url).resolve(`${pkg}/package.json`));
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function engineBinaryReady(root) {
111
+ const binDir = join(root, 'bin');
112
+ const token = `${process.platform}-${process.arch}`;
113
+ const wantSuffix = process.platform === 'win32' ? '.exe' : '';
114
+ try {
115
+ return readdirSync(binDir).some(name => {
116
+ if (name.endsWith('.sha256') || !name.includes(token)) return false;
117
+ return name.slice(name.indexOf(token) + token.length) === wantSuffix;
118
+ });
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ async function ensureEngine(pkg) {
125
+ const root = engineRoot(pkg);
126
+ if (!root) return { package: pkg, ok: false, reason: 'not-installed' };
127
+ if (engineBinaryReady(root)) return { package: pkg, ok: true, binary: 'present' };
128
+ const script = join(root, 'scripts', 'postinstall.js');
129
+ try {
130
+ if (!statSync(script).isFile()) return { package: pkg, ok: false, reason: 'no-postinstall' };
131
+ } catch {
132
+ return { package: pkg, ok: false, reason: 'no-postinstall' };
133
+ }
134
+ try {
135
+ execFileSync(process.execPath, [script], { cwd: root, stdio: 'ignore', timeout: 600000, windowsHide: true });
136
+ } catch (error) {
137
+ return { package: pkg, ok: false, reason: 'postinstall-failed', message: error?.message ?? String(error) };
138
+ }
139
+ return { package: pkg, ok: engineBinaryReady(root), binary: 'downloaded' };
140
+ }
141
+
142
+ async function ensureEngines() {
143
+ const results = [];
144
+ for (const pkg of ENGINE_PACKAGES) results.push(await ensureEngine(pkg));
145
+ return results;
146
+ }
147
+
148
+ // Internal hook so a diagnostic can exercise the repair without a host.
149
+ export { ensureEngines as ensureEngineBinaries };
150
+
151
+ // One in-process mcp-client per lane. stdio transport with our own files, so
152
+ // the same code path serves a checkout and a registry install.
153
+ async function mountMcpServers(ctx) {
154
+ const { bridge, resolveErrors } = await loadMcpBridge();
155
+ if (!bridge) {
156
+ mcpStatus = { mounted: false, servers: [], resolveErrors };
157
+ ctx.logger.warn('dsh-arya-computer-use: could not resolve @deepseek-ai/dsh-mcp-client, no desktop or browser tools: %s', resolveErrors.join(' | '));
158
+ return mcpStatus;
159
+ }
160
+ const lanes = [
161
+ { serverName: 'arya_desktop', args: [join(HERE, 'mcp', 'adapter-server.mjs')] },
162
+ { serverName: 'arya_browser', args: [join(HERE, 'mcp', 'browser-launcher.mjs')] },
163
+ ];
164
+ for (const lane of lanes) {
165
+ try {
166
+ ctx.plugin(bridge, {
167
+ transport: 'stdio',
168
+ serverName: lane.serverName,
169
+ command: process.execPath,
170
+ args: lane.args,
171
+ toolCallTimeoutMs: 120000,
172
+ failOnStartupError: false,
173
+ });
174
+ } catch (error) {
175
+ resolveErrors.push(`${lane.serverName}: ${error?.message ?? String(error)}`);
176
+ }
177
+ }
178
+ mcpStatus = { mounted: true, servers: lanes.map(lane => lane.serverName), resolveErrors };
179
+ return mcpStatus;
180
+ }
181
+
182
+ // Control files shared with the MCP adapter: the adapter polls settings.json
183
+ // for the behavior switches; the host publishes activity.json and TakeoverControl
184
+ // reads it. Both sides compute this path the same way (plugin-owned directory;
185
+ // the engine's private-DACL check applies only to its own state root).
186
+ export function controlDir() {
187
+ const local = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
188
+ return join(local, 'dsh-plugin-arya-desktop', 'control');
189
+ }
190
+
191
+ /* ------------------------------------------------------------------ schema */
192
+
193
+ const fields = {
194
+ progressOverlayEnabled: z.boolean().default(true).description('Progress overlay'),
195
+ progressOverlayMode: z.const('source').default('source').description('Overlay content: source'),
196
+ progressOverlayOpacity: z.number().min(20).max(100).default(60).description('Overlay opacity (%)'),
197
+ progressOverlayWidth: z.number().min(240).max(1600).default(360).description('Overlay width'),
198
+ progressOverlayHeight: z.number().min(160).max(1600).default(240).description('Overlay height'),
199
+ progressOverlayPosition: z.union(['top-right', 'top-left', 'bottom-right', 'bottom-left', 'custom'].map(value => z.const(value))).default('top-right').description('Overlay position'),
200
+ progressOverlayX: z.number().min(-32768).max(32767).default(80).description('Overlay X'),
201
+ progressOverlayY: z.number().min(-32768).max(32767).default(80).description('Overlay Y'),
202
+ progressOverlayLocked: z.boolean().default(true).description('Lock overlay and pass through input'),
203
+ progressLockShortcut: z.string().pattern(STOP_SHORTCUT_PATTERN).default('').description('Overlay lock shortcut'),
204
+ progressPreviewToken: z.number().min(0).default(0),
205
+ indicatorEnabled: z.boolean().default(true).description('Frame the app window the agent drives'),
206
+ cursorIndicatorEnabled: z.boolean().default(true).description('Show a ghost cursor where the agent acts'),
207
+ remoteIndicatorVisible: z.boolean().default(true).description('Visible in remote sessions and recordings'),
208
+ indicatorText: z.string().default(DEFAULT_SETTINGS.indicatorText).description('Banner text'),
209
+ pointerLabel: z.string().default(DEFAULT_SETTINGS.pointerLabel).description('Agent name used by the ghost cursor tag and the progress panel header'),
210
+ indicatorPosition: z.union([z.const('top'), z.const('bottom')]).default('top').description('Banner position'),
211
+ indicatorIdleMs: z.number().min(200).max(3000).default(800).description('Hide delay after the turn (ms)'),
212
+ stopShortcut: z.string().pattern(STOP_SHORTCUT_PATTERN).default('').description('Custom stop shortcut'),
213
+ resumeToken: z.number().min(0).default(0),
214
+ previewToken: z.number().min(0).default(0),
215
+ headedDefault: z.boolean().default(false).description('Send input with the real keyboard and mouse by default'),
216
+ overlayDefault: z.boolean().default(true).description('Start the cursor overlay with each desktop session'),
217
+ driverLockEnabled: z.boolean().default(true).description('One conversation drives the desktop at a time'),
218
+ toolPreset: z.union(['lite', 'lazy'].map(value => z.const(value))).default('lite').description('Tool surface size: lite lists all 16 tools; lazy starts with tools/snapshot/click and reveals the rest on demand'),
219
+ browserTools: z.union(['core', 'network', 'state', 'debug', 'tabs', 'react', 'mobile', 'all'].map(value => z.const(value))).default('core').description('agent-browser tool profiles exposed on the MCP row'),
220
+ browserProfile: z.string().default('').description('Chrome profile name or directory the web lane reuses for sign-ins'),
221
+ browserSession: z.string().default('').description('Isolated browser session key'),
222
+ browserStateFile: z.string().default('').description('Saved sign-in state file for the web lane'),
223
+ browserCdp: z.string().default('').description('CDP endpoint of a browser to attach to instead of launching one'),
224
+ browserHeaded: z.boolean().default(false).description('Show the web lane browser window'),
225
+ };
226
+ export const SettingsConfig = z.object(fields);
227
+
228
+ export function createLiveReference(value) {
229
+ let current = Array.isArray(value) ? Object.freeze([...value]) : value;
230
+ return Object.freeze({ get: () => current, [Symbol.for('cosmokit.volatile.write')]: next => { current = next; } });
231
+ }
232
+ // Some Desktop loaders resolve Schemastery without the volatile() convenience
233
+ // method. Never silently omit the metadata the host uses to expose the form.
234
+ export const Config = z.object({
235
+ ...Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, typeof field.volatile === 'function' ? field.volatile() : field.extra('volatile', true)])),
236
+ });
237
+ // A saved choice this version no longer offers must not keep the plugin from
238
+ // loading: DSH refuses the whole entry when one value fails, and every tool
239
+ // then disappears with no visible error (#39). The validator repairs such
240
+ // values to defaults and reports what changed.
241
+ export const REPAIRED_SETTINGS = Symbol.for('dsh-plugin-arya-computer-use.repairedSettings');
242
+ const validation = Config['~standard'];
243
+ Object.defineProperty(Config, '~standard', { value: { ...validation, validate(input) {
244
+ let result = validation.validate(input), repaired = [];
245
+ if (result.issues) {
246
+ const repair = repairStaleSettings(input);
247
+ if (repair.repaired.length) { const retry = validation.validate(repair.value); if (!retry.issues) { result = retry; repaired = repair.repaired; } }
248
+ }
249
+ if (!result.issues) {
250
+ try { normalizeSettings(result.value); } catch (error) { return { issues: [{ message: error.message }] }; }
251
+ for (const key of Object.keys(fields)) {
252
+ if (typeof result.value[key]?.get !== 'function') result.value[key] = createLiveReference(result.value[key]);
253
+ }
254
+ if (repaired.length) Object.defineProperty(result.value, REPAIRED_SETTINGS, { value: Object.freeze(repaired) });
255
+ }
256
+ return result;
257
+ } } });
258
+
259
+ /* -------------------------------------------------------------------- main */
260
+
261
+ export async function apply(ctx, config) {
262
+ registerSkillProvider(ctx);
263
+ void (async () => {
264
+ try {
265
+ const engines = await ensureEngines();
266
+ mcpStatus = { ...mcpStatus, engines };
267
+ const broken = engines.filter(engine => !engine.ok);
268
+ if (broken.length) {
269
+ ctx.logger.warn(
270
+ 'dsh-arya-computer-use: engine binaries are missing: %s (run the vendor postinstall, or approve dependency build scripts in the profile)',
271
+ broken.map(engine => `${engine.package}${engine.reason ? ` (${engine.reason})` : ''}`).join(', '),
272
+ );
273
+ }
274
+ } catch (error) {
275
+ ctx.logger.warn('dsh-arya-computer-use: could not prepare engine binaries: %s', error?.message ?? String(error));
276
+ }
277
+ await mountMcpServers(ctx);
278
+ })();
279
+
280
+ const root = controlDir();
281
+ const runtime = { configFile: join(root, 'settings.json'), stateFile: join(root, 'state.json'), activityFile: join(root, 'activity.json'), layoutFile: join(root, 'overlay-layout.json'), targetFile: join(root, 'target.json') };
282
+
283
+ let desired = normalizeSettings(config), stopped = false;
284
+ if (config?.[REPAIRED_SETTINGS]?.length) {
285
+ ctx.logger.warn('dsh-arya-computer-use: saved settings this version does not offer were reset to their defaults: %s', config[REPAIRED_SETTINGS].join('; '));
286
+ }
287
+ atomicJson(runtime.configFile, desired);
288
+
289
+ // The takeover helper owns the overlay windows, the banner, the cursor
290
+ // indicator, the global Esc kill, and the paused/stopped series state.
291
+ const takeover = new TakeoverControl({
292
+ configFile: runtime.configFile, stateFile: runtime.stateFile, activityFile: runtime.activityFile, targetFile: runtime.targetFile,
293
+ config: desired,
294
+ onStop: error => ctx.logger.warn('dsh-arya-computer-use: %s', error?.message ?? error),
295
+ });
296
+ ctx.effect(() => () => { stopped = true; takeover.close().catch(() => {}); });
297
+
298
+ // Host-side activity lease: publishes activity.json (drives begin/end/
299
+ // progress on the takeover helper) and enforces the driver lock.
300
+ installActivity(ctx, runtime.activityFile, { getSettings: () => desired });
301
+
302
+ const applySettings = next => {
303
+ // Windows readers can briefly deny replacement. Keep desired unchanged on
304
+ // a failed write so the next poll retries the pending settings.
305
+ const nextSettings = normalizeSettings(next);
306
+ atomicJson(runtime.configFile, nextSettings);
307
+ desired = nextSettings;
308
+ return Promise.resolve();
309
+ };
310
+
311
+ let saveOverlayPatch, layoutId, layoutSaving = false, layoutRetry = { id: undefined, at: 0, delay: 0 };
312
+ ctx.effect(() => {
313
+ const timer = setInterval(() => {
314
+ if (saveOverlayPatch && !layoutSaving) {
315
+ try {
316
+ const layout = JSON.parse(readFileSync(runtime.layoutFile, 'utf8'));
317
+ // A failed save is retried with backoff (0.5 s, doubling up to 60 s)
318
+ // and a newer layout at once. A patch that cannot apply is dropped.
319
+ if (layout.id !== layoutId && !(layout.id === layoutRetry.id && Date.now() < layoutRetry.at)) {
320
+ const allowed = ['progressOverlayWidth', 'progressOverlayHeight', 'progressOverlayPosition', 'progressOverlayX', 'progressOverlayY', 'progressOverlayLocked', 'progressOverlayEnabled'];
321
+ let usable = false;
322
+ try {
323
+ if (!layout.patch || typeof layout.patch !== 'object' || Object.keys(layout.patch).some(key => !allowed.includes(key))) throw Error('Invalid overlay layout patch');
324
+ normalizeSettings({ ...desired, ...layout.patch });
325
+ usable = !(layout.base && Object.entries(layout.base).some(([key, value]) => !allowed.includes(key) || desired[key] !== value));
326
+ } finally { if (!usable) layoutId = layout.id; }
327
+ if (usable) {
328
+ layoutSaving = true;
329
+ Promise.resolve().then(() => saveOverlayPatch(layout.patch)).then(() => {
330
+ layoutId = layout.id;
331
+ layoutRetry = { id: undefined, at: 0, delay: 0 };
332
+ }, error => {
333
+ const delay = layoutRetry.id === layout.id ? Math.min(layoutRetry.delay * 2, 60000) : 500;
334
+ layoutRetry = { id: layout.id, at: Date.now() + delay, delay };
335
+ ctx.logger.error(error);
336
+ }).finally(() => { layoutSaving = false; });
337
+ }
338
+ }
339
+ } catch (error) { if (error.code !== 'ENOENT') ctx.logger.error(error); }
340
+ }
341
+ // Older validators keep plain values rather than volatile references.
342
+ // The raw entry still holds any value the validator reset; reset it here too.
343
+ try {
344
+ const next = normalizeSettings({ ...config, ...repairStaleSettings(ctx.fiber.entry?.options?.config).value });
345
+ if (JSON.stringify(next) !== JSON.stringify(desired)) applySettings(next);
346
+ } catch (error) { ctx.logger.error(error); }
347
+ }, 250);
348
+ timer.unref();
349
+ return () => clearInterval(timer);
350
+ });
351
+
352
+ ctx.inject(['settings'], settingsContext => {
353
+ if (typeof settingsContext.settings.register !== 'function') {
354
+ if (typeof settingsContext.settings.update === 'function') settingsContext.inject(['configEditor'], owner => {
355
+ // DSH 0.2.2's Loader (cordis 4.1) keeps a wrapper of the plugin's fiber
356
+ // on its entry where 0.2.1 kept the fiber itself; the fiber knows its
357
+ // entry either way (#72).
358
+ saveOverlayPatch = patch => {
359
+ const entries = owner.configEditor.entries();
360
+ const entry = entries.find(entry => entry === ctx.fiber.entry) ?? entries.find(entry => entry.fiber === ctx.fiber || entry.fiber?.ctx?.fiber === ctx.fiber);
361
+ if (!entry) throw Error('DSH lists no editable profile entry for this plugin (is it listed twice, or inside another entry?), so overlay moves and resizes are not saved.');
362
+ return owner.settings.update(entry.options.id, patch);
363
+ };
364
+ return () => { saveOverlayPatch = undefined; };
365
+ });
366
+ // DSH 0.2 uses volatile Config fields keyed by the profile entry id.
367
+ if (settingsContext.settings.configure) settingsContext.effect(() => settingsContext.settings.configure({ auto: false }, ctx.fiber));
368
+ return;
369
+ }
370
+ const scope = settingsContext.settings.register('arya-computer-use', SettingsConfig, {
371
+ base: normalizeSettings(config), applies: 'live', validate: normalizeSettings,
372
+ });
373
+ saveOverlayPatch = patch => scope.update(patch);
374
+ desired = normalizeSettings(scope.get());
375
+ atomicJson(runtime.configFile, desired);
376
+ settingsContext.effect(() => scope.watch(applySettings));
377
+ });
378
+
379
+ // Loopback-only status endpoint (pattern derived from @axyatech/dsh-plugin-rea).
380
+ ctx.inject(['webServer'], web => {
381
+ web.effect(() => web.webServer.register({ kind: 'exact', path: STATUS_PATH, handler: statusHandler }), 'arya-computer-use: status route');
382
+ });
383
+ function statusHandler(req, res) {
384
+ if (!isLoopbackAddress(req?.socket?.remoteAddress)) return sendJson(res, 403, { error: 'loopback_only' });
385
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
386
+ res.writeHead(405, { Allow: 'GET, HEAD' });
387
+ return res.end();
388
+ }
389
+ const readJson = file => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } };
390
+ const maskHome = text => String(text).split(os.homedir()).join('~');
391
+ const activity = readJson(runtime.activityFile);
392
+ sendJson(res, 200, {
393
+ plugin: '@aryacode/dsh-plugin-arya-computer-use',
394
+ version: PLUGIN_VERSION,
395
+ controlDir: maskHome(root),
396
+ settings: desired,
397
+ takeover: { running: !!takeover.child, paused: takeover.paused, seriesActive: takeover.seriesHeld, stoppedSeriesKey: takeover.stoppedSeriesKey },
398
+ driver: activity?.active ? { sessionId: activity.sessionId, turn: activity.turn } : null,
399
+ mcp: mcpStatus,
400
+ state: readJson(runtime.stateFile),
401
+ });
402
+ }
403
+ return () => {};
404
+ }
405
+
406
+ function isLoopbackAddress(address) {
407
+ if (typeof address !== 'string') return false;
408
+ return address === '::1' || address === '::ffff:7f00:1' || address.startsWith('127.') || address.startsWith('::ffff:127.');
409
+ }
410
+ function sendJson(res, code, body) {
411
+ res.writeHead(code, {
412
+ 'Content-Type': 'application/json; charset=utf-8',
413
+ 'Cache-Control': 'no-store',
414
+ 'X-Content-Type-Options': 'nosniff',
415
+ 'Cross-Origin-Resource-Policy': 'same-origin',
416
+ 'Referrer-Policy': 'no-referrer',
417
+ });
418
+ res.end(JSON.stringify(body));
419
+ }
420
+
421
+ /* ----------------------------------------------------------- skill provider */
422
+
423
+ /** Register the workflow skill as a provider, global and per-agent. */
424
+ function registerSkillProvider(ctx) {
425
+ ctx.inject(['skills'], (scoped) => {
426
+ scoped.effect(
427
+ () => scoped.skills.registerProvider(() => createSkillProvider()),
428
+ 'arya-computer-use: workflow skill provider',
429
+ );
430
+ });
431
+
432
+ // A global provider cannot outrank a skill the agent's own composition
433
+ // contributes from a nearer layer. A provider registered through the
434
+ // agent's own context joins that layer.
435
+ const agentProviders = new Map();
436
+ ctx.effect(
437
+ () => () => {
438
+ for (const fiber of agentProviders.values()) fiber.dispose?.();
439
+ agentProviders.clear();
440
+ },
441
+ 'arya-computer-use: agent skill providers',
442
+ );
443
+
444
+ ctx.on('agent/created', async ({ agent }) => {
445
+ if (agentProviders.has(agent.id)) return;
446
+ try {
447
+ if (agent.ctx.get('skills') === undefined) return;
448
+ const fiber = agent.ctx.inject(['skills'], (scoped) => {
449
+ scoped.effect(
450
+ () => scoped.skills.registerProvider(() => createSkillProvider()),
451
+ 'arya-computer-use: agent workflow skill provider',
452
+ );
453
+ });
454
+ await fiber;
455
+ agentProviders.set(agent.id, fiber);
456
+ } catch {
457
+ // A skill registration must never fail agent creation.
458
+ }
459
+ });
460
+
461
+ ctx.on('agent/disposed', ({ agent }) => {
462
+ const fiber = agentProviders.get(agent.id);
463
+ if (fiber === undefined) return;
464
+ fiber.dispose?.();
465
+ agentProviders.delete(agent.id);
466
+ });
467
+ }
468
+
469
+ function createSkillProvider() {
470
+ return {
471
+ name: PROVIDER_NAME,
472
+ async list(options) {
473
+ const parsed = await readSkill(SKILL_FILE, options?.signal);
474
+ if (parsed === undefined) return { candidates: [], complete: true };
475
+ return {
476
+ candidates: [
477
+ {
478
+ ...summaryOf(parsed),
479
+ provider: PROVIDER_NAME,
480
+ source: 'runtime',
481
+ rank: RANK,
482
+ locator: { file: SKILL_FILE, directory: SKILL_DIR },
483
+ resourceBase: { kind: 'directory', path: SKILL_DIR },
484
+ path: SKILL_FILE,
485
+ },
486
+ ],
487
+ complete: true,
488
+ };
489
+ },
490
+ async get(candidate, options) {
491
+ const file = candidate?.locator?.file;
492
+ if (typeof file !== 'string') return undefined;
493
+ const parsed = await readSkill(file, options?.signal);
494
+ if (parsed === undefined) return undefined;
495
+ return {
496
+ ...summaryOf(parsed),
497
+ source: candidate.source,
498
+ provider: PROVIDER_NAME,
499
+ resourceBase: { kind: 'directory', path: SKILL_DIR },
500
+ path: parsed.path,
501
+ ...(parsed.metadata === undefined ? {} : { metadata: parsed.metadata }),
502
+ content: parsed.body,
503
+ };
504
+ },
505
+ };
506
+ }
507
+
508
+ function summaryOf(parsed) {
509
+ return {
510
+ name: parsed.name,
511
+ description: parsed.description,
512
+ ...(parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse }),
513
+ invocation: parsed.invocation,
514
+ };
515
+ }
516
+
517
+ async function readSkill(file, signal) {
518
+ let raw;
519
+ try {
520
+ raw = await readFile(file, { encoding: 'utf8', signal });
521
+ } catch {
522
+ return undefined;
523
+ }
524
+ const parsed = parseFrontmatter(raw);
525
+ if (parsed === undefined) return undefined;
526
+ const name = stringField(parsed.data, 'name');
527
+ const description = stringField(parsed.data, 'description');
528
+ if (name === undefined || description === undefined) return undefined;
529
+ const metadata = metadataOf(parsed.data);
530
+ return {
531
+ path: file,
532
+ name,
533
+ description,
534
+ whenToUse: stringField(parsed.data, 'whenToUse'),
535
+ invocation: invocationOf(parsed.data),
536
+ ...(metadata === undefined ? {} : { metadata }),
537
+ body: parsed.body,
538
+ };
539
+ }
540
+
541
+ /** Minimal SKILL.md frontmatter reader: flat scalars plus one nested `metadata` map. */
542
+ function parseFrontmatter(raw) {
543
+ const text = String(raw).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
544
+ if (!text.startsWith('---\n')) return undefined;
545
+ const end = text.indexOf('\n---', 4);
546
+ if (end === -1) return undefined;
547
+ const head = text.slice(4, end);
548
+ const body = text.slice(end + 4).replace(/^\n/, '');
549
+
550
+ const data = Object.create(null);
551
+ let nested;
552
+ for (const line of head.split('\n')) {
553
+ if (line.trim().length === 0 || line.trimStart().startsWith('#')) continue;
554
+ const match = /^(\s*)([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
555
+ if (match === null) continue;
556
+ const indented = match[1].length > 0;
557
+ const key = match[2];
558
+ const value = match[3].trim();
559
+ if (indented && nested !== undefined) {
560
+ if (data[nested] === undefined || typeof data[nested] !== 'object') data[nested] = Object.create(null);
561
+ data[nested][key] = unquote(value);
562
+ continue;
563
+ }
564
+ if (value.length === 0) {
565
+ nested = key;
566
+ data[key] = Object.create(null);
567
+ } else {
568
+ nested = undefined;
569
+ data[key] = unquote(value);
570
+ }
571
+ }
572
+ return { data, body: body.trim() };
573
+ }
574
+
575
+ function metadataOf(data) {
576
+ const metadata = data?.metadata;
577
+ if (metadata === undefined || typeof metadata !== 'object' || metadata === null) return undefined;
578
+ const entries = Object.entries(metadata);
579
+ return entries.length === 0 ? undefined : Object.fromEntries(entries);
580
+ }
581
+
582
+ function invocationOf(data) {
583
+ const disableModelInvocation = booleanField(data, 'disable-model-invocation');
584
+ const userInvocable = booleanField(data, 'user-invocable');
585
+ return {
586
+ modelInvocable: disableModelInvocation !== true,
587
+ userInvocable: userInvocable !== false,
588
+ };
589
+ }
590
+
591
+ function booleanField(data, key) {
592
+ if (!Object.hasOwn(data, key)) return undefined;
593
+ const value = data[key];
594
+ if (typeof value === 'boolean') return value;
595
+ if (value === 1 || value === '1' || value === 'true') return true;
596
+ if (value === 0 || value === '0' || value === 'false') return false;
597
+ return undefined;
598
+ }
599
+
600
+ function stringField(data, key) {
601
+ if (!Object.hasOwn(data, key)) return undefined;
602
+ const value = data[key];
603
+ if (typeof value !== 'string') return undefined;
604
+ const trimmed = value.trim();
605
+ return trimmed.length === 0 ? undefined : trimmed;
606
+ }
607
+
608
+ function unquote(value) {
609
+ if (value.length >= 2) {
610
+ const first = value[0];
611
+ const last = value[value.length - 1];
612
+ if (first === '"' && last === '"') {
613
+ try {
614
+ return JSON.parse(value);
615
+ } catch {
616
+ return value.slice(1, -1);
617
+ }
618
+ }
619
+ if (first === "'" && last === "'") return value.slice(1, -1).replace(/''/g, "'");
620
+ }
621
+ return value;
622
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Arya Computer Use",
4
+ "description": "Drives Windows apps through accessibility refs (no pixel math) with a takeover indicator, plus a web lane — replaces dsh-computer-use-win."
5
+ }
6
+ }