@anusornneal/chat-relay 0.8.0 → 0.9.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.
@@ -1,440 +1,424 @@
1
- import { spawn } from "node:child_process";
2
- import { fileURLToPath } from "node:url";
3
- import path from "node:path";
4
- import readline from "node:readline";
5
- import { randomUUID } from "node:crypto";
6
- import { BoundedLane } from "./capability-scheduler.mjs";
7
-
8
- const MAX_SCREENSHOT_BINARY_BYTES = 32 * 1024;
9
- const POWERSHELL_TIMEOUT_MS = 15_000;
10
- const MAX_TEXT_LENGTH = 8192;
11
- const MAX_CLIPBOARD_TEXT_LENGTH = 8192;
12
- const MIN_SCREENSHOT_WIDTH = 320;
13
- const MAX_SCREENSHOT_WIDTH = 1920;
14
- const MIN_SCREENSHOT_QUALITY = 20;
15
- const MAX_SCREENSHOT_QUALITY = 85;
16
- const MAX_WINDOW_LIST_LIMIT = 100;
17
- const helperPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "windows-desktop-worker.ps1");
18
-
19
- const KEY_CODES = new Map([
20
- ["enter", 0x0D],
21
- ["tab", 0x09],
22
- ["escape", 0x1B],
23
- ["backspace", 0x08],
24
- ["delete", 0x2E],
25
- ["arrowleft", 0x25],
26
- ["arrowup", 0x26],
27
- ["arrowright", 0x27],
28
- ["arrowdown", 0x28],
29
- ["home", 0x24],
30
- ["end", 0x23],
31
- ["pageup", 0x21],
32
- ["pagedown", 0x22],
33
- ["space", 0x20],
34
- ...Array.from({ length: 12 }, (_, index) => ["f" + (index + 1), 0x70 + index]),
35
- ...Array.from({ length: 26 }, (_, index) => [String.fromCharCode(97 + index), 0x41 + index]),
36
- ...Array.from({ length: 10 }, (_, index) => [String(index), 0x30 + index]),
37
- ]);
38
-
39
- function normalizedKey(value) {
40
- return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
41
- }
42
-
43
- function normalizeKeyboardInput(input = {}) {
44
- const hasText = typeof input.text === "string" && input.text.length > 0;
45
- const hasKey = typeof input.key === "string" && input.key.trim().length > 0;
46
-
47
- if (hasText === hasKey) return { ok: false, error: "invalid_keyboard_input" };
48
-
49
- if (hasText) {
50
- if (input.text.length > MAX_TEXT_LENGTH) return { ok: false, error: "text_too_large" };
51
- if (input.ctrl || input.alt || input.shift || input.win) {
52
- return { ok: false, error: "invalid_keyboard_input" };
53
- }
54
- return { ok: true, payload: { text: input.text } };
55
- }
56
-
57
- const key = normalizedKey(input.key);
58
- const keyCode = KEY_CODES.get(key);
59
- if (!keyCode) return { ok: false, error: "unsupported_key" };
60
-
61
- return {
62
- ok: true,
63
- payload: {
64
- keyCode,
65
- ctrl: input.ctrl === true,
66
- alt: input.alt === true,
67
- shift: input.shift === true,
68
- win: input.win === true,
69
- },
70
- };
71
- }
72
-
73
- class PersistentDesktopRunner {
74
- constructor(options = {}) {
75
- this.platform = options.platform ?? process.platform;
76
- this.timeoutMs = Number(options.timeoutMs ?? POWERSHELL_TIMEOUT_MS);
77
- this.process = null;
78
- this.stdout = null;
79
- this.pending = new Map();
80
- this.stderr = "";
81
- }
82
-
83
- ensureStarted() {
84
- if (this.platform !== "win32") return false;
85
- if (this.process && !this.process.killed) return true;
86
-
87
- const child = spawn("powershell.exe", [
88
- "-NoLogo",
89
- "-NoProfile",
90
- "-NonInteractive",
91
- "-Sta",
92
- "-ExecutionPolicy",
93
- "Bypass",
94
- "-File",
95
- helperPath,
96
- ], {
97
- windowsHide: true,
98
- stdio: ["pipe", "pipe", "pipe"],
99
- });
100
-
101
- this.process = child;
102
- this.stderr = "";
103
- this.stdout = readline.createInterface({ input: child.stdout });
104
-
105
- this.stdout.on("line", (line) => {
106
- let message;
107
- try {
108
- message = JSON.parse(line);
109
- } catch {
110
- return;
111
- }
112
- const pending = this.pending.get(message?.id);
113
- if (!pending) return;
114
- clearTimeout(pending.timer);
115
- this.pending.delete(message.id);
116
- pending.resolve(message.result ?? { ok: false, error: "desktop_worker_error" });
117
- });
118
-
119
- child.stderr.on("data", (chunk) => {
120
- this.stderr = (this.stderr + String(chunk)).slice(-8192);
121
- });
122
-
123
- child.on("exit", () => {
124
- const error = { ok: false, error: "desktop_worker_exited" };
125
- for (const pending of this.pending.values()) {
126
- clearTimeout(pending.timer);
127
- pending.resolve(error);
128
- }
129
- this.pending.clear();
130
- this.stdout?.close();
131
- this.stdout = null;
132
- this.process = null;
133
- });
134
-
135
- child.on("error", () => {
136
- // exit handler settles pending work.
137
- });
138
-
139
- return true;
140
- }
141
-
142
- async run(operation, args) {
143
- if (!this.ensureStarted() || !this.process?.stdin?.writable) {
144
- return { ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" };
145
- }
146
-
147
- const id = randomUUID();
148
- const request = JSON.stringify({ id, operation, args: args || {} }) + "\n";
149
-
150
- return new Promise((resolve) => {
151
- const timer = setTimeout(() => {
152
- this.pending.delete(id);
153
- resolve({ ok: false, error: operation === "screenshot" ? "capture_timeout" : "input_timeout" });
154
- }, this.timeoutMs);
155
-
156
- this.pending.set(id, { resolve, timer });
157
- this.process.stdin.write(request, (error) => {
158
- if (!error) return;
159
- clearTimeout(timer);
160
- this.pending.delete(id);
161
- resolve({ ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" });
162
- });
163
- });
164
- }
165
-
166
- close() {
167
- try { this.stdout?.close(); } catch {}
168
- try { this.process?.kill(); } catch {}
169
- this.stdout = null;
170
- this.process = null;
171
- }
172
- }
173
-
174
- export function createDesktopPlatformAdapter(options = {}) {
175
- const platform = options.platform ?? process.platform;
176
- const customRunner = typeof options.runner === "function" ? options.runner : null;
177
-
178
- if (platform === "win32") {
179
- const persistentRunner = customRunner ? null : (options.persistentRunner ?? new PersistentDesktopRunner({ platform }));
180
- return {
181
- platform,
182
- supported: true,
183
- transport: "persistent-worker",
184
- run: customRunner ?? ((operation, args) => persistentRunner.run(operation, args)),
185
- close: () => persistentRunner?.close?.(),
186
- };
187
- }
188
-
189
- return {
190
- platform,
191
- supported: false,
192
- transport: "unsupported",
193
- run: customRunner ?? (async () => ({ ok: false, error: "unsupported_platform" })),
194
- close: () => {},
195
- };
196
- }
197
-
198
- export class DesktopManager {
199
- constructor(options = {}) {
200
- this.enabled = options.enabled ?? process.env.DESKTOP_ENABLED === "1";
201
- this.adapter = options.adapter ?? createDesktopPlatformAdapter({
202
- platform: options.platform,
203
- persistentRunner: options.persistentRunner,
204
- runner: options.runner,
205
- });
206
- this.platform = this.adapter.platform;
207
- this.runner = (operation, args) => this.adapter.run(operation, args);
208
- this.controlLane = options.controlLane ?? new BoundedLane({
209
- name: "desktop_control",
210
- concurrency: 1,
211
- maxQueued: options.controlMaxQueued ?? process.env.DESKTOP_CONTROL_MAX_QUEUED,
212
- queueTimeoutMs: options.controlQueueTimeoutMs ?? process.env.AGENT_QUEUE_TIMEOUT_MS,
213
- });
214
- }
215
-
216
- getConfig() {
217
- return {
218
- enabled: this.enabled,
219
- supported: this.adapter.supported,
220
- platform: this.adapter.platform,
221
- transport: this.adapter.transport,
222
- controlQueue: this.controlLane.snapshot(),
223
- };
224
- }
225
-
226
- gate() {
227
- if (!this.enabled) return { ok: false, error: "desktop_disabled" };
228
- if (!this.adapter.supported) return { ok: false, error: "unsupported_platform" };
229
- return null;
230
- }
231
-
232
- async runControl(work) {
233
- try {
234
- return await this.controlLane.run(work);
235
- } catch (error) {
236
- return { ok: false, error: error instanceof Error ? error.message : "desktop_queue_error" };
237
- }
238
- }
239
-
240
- async screenshot(input = {}) {
241
- const gate = this.gate();
242
- if (gate) return gate;
243
-
244
- const monitor = input.monitor ?? "primary";
245
- if (!(monitor === "primary" || monitor === "secondary" || (Number.isInteger(monitor) && monitor >= 0 && monitor <= 15))) {
246
- return { ok: false, error: "invalid_monitor" };
247
- }
248
-
249
- const maxWidth = input.maxWidth === undefined ? 960 : Number(input.maxWidth);
250
- if (!Number.isInteger(maxWidth) || maxWidth < MIN_SCREENSHOT_WIDTH || maxWidth > MAX_SCREENSHOT_WIDTH) {
251
- return { ok: false, error: "invalid_max_width" };
252
- }
253
- const quality = input.quality === undefined ? 58 : Number(input.quality);
254
- if (!Number.isInteger(quality) || quality < MIN_SCREENSHOT_QUALITY || quality > MAX_SCREENSHOT_QUALITY) {
255
- return { ok: false, error: "invalid_quality" };
256
- }
257
-
258
- const result = await this.runner("screenshot", { monitor, maxWidth, quality });
259
- return this.normalizeScreenshotResult(result);
260
- }
261
-
262
- normalizeScreenshotResult(result) {
263
- if (!result?.ok) return result || { ok: false, error: "capture_failed" };
264
- if (result.mimeType !== "image/jpeg" || typeof result.data !== "string") {
265
- return { ok: false, error: "capture_failed" };
266
- }
267
-
268
- const binaryBytes = Buffer.from(result.data, "base64").byteLength;
269
- if (binaryBytes <= 0 || binaryBytes > MAX_SCREENSHOT_BINARY_BYTES) {
270
- return { ok: false, error: "screenshot_too_large" };
271
- }
272
-
273
- return {
274
- ...result,
275
- ok: true,
276
- width: Number(result.width),
277
- height: Number(result.height),
278
- desktopOriginX: Number(result.desktopOriginX),
279
- desktopOriginY: Number(result.desktopOriginY),
280
- desktopWidth: Number(result.desktopWidth),
281
- desktopHeight: Number(result.desktopHeight),
282
- scaleX: Number(result.scaleX),
283
- scaleY: Number(result.scaleY),
284
- monitorIndex: Number(result.monitorIndex),
285
- isPrimary: result.isPrimary === true,
286
- deviceName: typeof result.deviceName === "string" ? result.deviceName : undefined,
287
- virtualDesktopOriginX: Number(result.virtualDesktopOriginX),
288
- virtualDesktopOriginY: Number(result.virtualDesktopOriginY),
289
- virtualDesktopWidth: Number(result.virtualDesktopWidth),
290
- virtualDesktopHeight: Number(result.virtualDesktopHeight),
291
- monitorCount: Number(result.monitorCount),
292
- jpegQuality: Number(result.jpegQuality),
293
- byteLength: binaryBytes,
294
- };
295
- }
296
-
297
- async clipboardRead() {
298
- const gate = this.gate();
299
- if (gate) return gate;
300
- const result = await this.runner("clipboard_read", {});
301
- if (!result?.ok || typeof result.text !== "string") return result || { ok: false, error: "clipboard_failed" };
302
- return { ok: true, text: result.text.slice(0, MAX_CLIPBOARD_TEXT_LENGTH), truncated: result.truncated === true };
303
- }
304
-
305
- async clipboardWrite(input = {}) {
306
- const gate = this.gate();
307
- if (gate) return gate;
308
- if (typeof input.text !== "string") return { ok: false, error: "invalid_clipboard_text" };
309
- if (input.text.length > MAX_CLIPBOARD_TEXT_LENGTH) return { ok: false, error: "text_too_large" };
310
- return this.runControl(() => this.runner("clipboard_write", { text: input.text }));
311
- }
312
-
313
- async listWindows(input = {}) {
314
- const gate = this.gate();
315
- if (gate) return gate;
316
- const limit = input.limit === undefined ? 50 : Number(input.limit);
317
- if (!Number.isInteger(limit) || limit < 1 || limit > MAX_WINDOW_LIST_LIMIT) return { ok: false, error: "invalid_window_limit" };
318
- const result = await this.runner("window_list", { limit });
319
- if (!result?.ok || !Array.isArray(result.windows)) return result || { ok: false, error: "window_list_failed" };
320
- const windows = result.windows.slice(0, limit).map((window) => ({
321
- windowId: String(window.windowId ?? window.WindowId ?? ""),
322
- processId: Number(window.processId ?? window.ProcessId),
323
- title: String(window.title ?? window.Title ?? ""),
324
- x: Number(window.x ?? window.X),
325
- y: Number(window.y ?? window.Y),
326
- width: Number(window.width ?? window.Width),
327
- height: Number(window.height ?? window.Height),
328
- isForeground: (window.isForeground ?? window.IsForeground) === true,
329
- }));
330
- return { ok: true, windows, count: Math.min(Number(result.count ?? result.Count) || windows.length, limit) };
331
- }
332
-
333
- async focusWindow(input = {}) {
334
- const gate = this.gate();
335
- if (gate) return gate;
336
- const windowId = String(input.windowId ?? "").trim();
337
- if (!/^[1-9][0-9]{0,19}$/.test(windowId)) return { ok: false, error: "invalid_window_id" };
338
- return this.runControl(() => this.runner("window_focus", { windowId }));
339
- }
340
-
341
- async mouseClick(input = {}) {
342
- const gate = this.gate();
343
- if (gate) return gate;
344
-
345
- const x = Number(input.x);
346
- const y = Number(input.y);
347
- const button = String(input.button || "left").toLowerCase();
348
- const clicks = Number(input.clicks ?? 1);
349
-
350
- if (!Number.isInteger(x) || !Number.isInteger(y)) return { ok: false, error: "invalid_coordinates" };
351
- if (!["left", "right", "middle"].includes(button)) return { ok: false, error: "invalid_button" };
352
- if (clicks !== 1 && clicks !== 2) return { ok: false, error: "invalid_click_count" };
353
-
354
- return this.runControl(() => this.runner("mouse_click", { x, y, button, clicks }));
355
- }
356
-
357
- async keyboardInput(input = {}) {
358
- const gate = this.gate();
359
- if (gate) return gate;
360
-
361
- const normalized = normalizeKeyboardInput(input);
362
- if (!normalized.ok) return normalized;
363
- return this.runControl(() => this.runner("keyboard_input", normalized.payload));
364
- }
365
-
366
- async step(input = {}) {
367
- const gate = this.gate();
368
- if (gate) return gate;
369
-
370
- const actions = Array.isArray(input.actions) ? input.actions : [];
371
- if (actions.length < 1 || actions.length > 20) return { ok: false, error: "invalid_step_actions" };
372
-
373
- const normalizedActions = [];
374
- for (const action of actions) {
375
- if (!action || typeof action !== "object") return { ok: false, error: "invalid_step_action" };
376
- if (action.type === "click") {
377
- const x = Number(action.x);
378
- const y = Number(action.y);
379
- const button = String(action.button || "left").toLowerCase();
380
- const clicks = Number(action.clicks ?? 1);
381
- if (!Number.isInteger(x) || !Number.isInteger(y)) return { ok: false, error: "invalid_coordinates" };
382
- if (!["left", "right", "middle"].includes(button)) return { ok: false, error: "invalid_button" };
383
- if (clicks !== 1 && clicks !== 2) return { ok: false, error: "invalid_click_count" };
384
- normalizedActions.push({ type: "click", x, y, button, clicks });
385
- continue;
386
- }
387
-
388
- if (action.type === "text" || action.type === "key") {
389
- const normalized = normalizeKeyboardInput(action);
390
- if (!normalized.ok) return normalized;
391
- normalizedActions.push({ type: action.type, ...normalized.payload });
392
- continue;
393
- }
394
-
395
- if (action.type === "wait") {
396
- const ms = Number(action.ms);
397
- if (!Number.isInteger(ms) || ms < 0 || ms > 5000) return { ok: false, error: "invalid_wait" };
398
- normalizedActions.push({ type: "wait", ms });
399
- continue;
400
- }
401
-
402
- return { ok: false, error: "invalid_step_action" };
403
- }
404
-
405
- const monitor = input.monitor ?? "primary";
406
- if (!(monitor === "primary" || monitor === "secondary" || (Number.isInteger(monitor) && monitor >= 0 && monitor <= 15))) {
407
- return { ok: false, error: "invalid_monitor" };
408
- }
409
-
410
- const settleMs = input.settleMs === undefined ? 120 : Number(input.settleMs);
411
- if (!Number.isInteger(settleMs) || settleMs < 0 || settleMs > 5000) return { ok: false, error: "invalid_settle_ms" };
412
-
413
- const wantsCapture = input.captureAfter !== false;
414
- const maxWidth = input.maxWidth === undefined ? 960 : Number(input.maxWidth);
415
- const quality = input.quality === undefined ? 58 : Number(input.quality);
416
- if (wantsCapture && (!Number.isInteger(maxWidth) || maxWidth < MIN_SCREENSHOT_WIDTH || maxWidth > MAX_SCREENSHOT_WIDTH)) {
417
- return { ok: false, error: "invalid_max_width" };
418
- }
419
- if (wantsCapture && (!Number.isInteger(quality) || quality < MIN_SCREENSHOT_QUALITY || quality > MAX_SCREENSHOT_QUALITY)) {
420
- return { ok: false, error: "invalid_quality" };
421
- }
422
-
423
- const result = await this.runControl(() => this.runner("step", {
424
- actions: normalizedActions,
425
- captureAfter: wantsCapture,
426
- settleMs,
427
- monitor,
428
- ...(wantsCapture ? { maxWidth, quality } : {}),
429
- }));
430
-
431
- if (input.captureAfter === false) return result;
432
- return this.normalizeScreenshotResult(result);
433
- }
434
-
435
- close() {
436
- this.adapter.close();
437
- }
438
- }
439
-
440
- export { KEY_CODES, MAX_SCREENSHOT_BINARY_BYTES, PersistentDesktopRunner };
1
+ import { BoundedLane } from "./capability-scheduler.mjs";
2
+ import { PersistentDesktopRunner } from "./windows-desktop-runner.mjs";
3
+ import { MacOSDesktopRunner } from "./macos-desktop-runner.mjs";
4
+
5
+ const MAX_SCREENSHOT_BINARY_BYTES = 32 * 1024;
6
+ const MAX_TEXT_LENGTH = 8192;
7
+ const MAX_CLIPBOARD_TEXT_LENGTH = 8192;
8
+ const MIN_SCREENSHOT_WIDTH = 320;
9
+ const MAX_SCREENSHOT_WIDTH = 1920;
10
+ const MIN_SCREENSHOT_QUALITY = 20;
11
+ const MAX_SCREENSHOT_QUALITY = 85;
12
+ const MAX_WINDOW_LIST_LIMIT = 100;
13
+
14
+ const KEY_CODES = new Map([
15
+ ["enter", 0x0D],
16
+ ["tab", 0x09],
17
+ ["escape", 0x1B],
18
+ ["backspace", 0x08],
19
+ ["delete", 0x2E],
20
+ ["arrowleft", 0x25],
21
+ ["arrowup", 0x26],
22
+ ["arrowright", 0x27],
23
+ ["arrowdown", 0x28],
24
+ ["home", 0x24],
25
+ ["end", 0x23],
26
+ ["pageup", 0x21],
27
+ ["pagedown", 0x22],
28
+ ["space", 0x20],
29
+ ...Array.from({ length: 12 }, (_, index) => ["f" + (index + 1), 0x70 + index]),
30
+ ...Array.from({ length: 26 }, (_, index) => [String.fromCharCode(97 + index), 0x41 + index]),
31
+ ...Array.from({ length: 10 }, (_, index) => [String(index), 0x30 + index]),
32
+ ]);
33
+
34
+ function normalizedKey(value) {
35
+ return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
36
+ }
37
+
38
+ function normalizeKeyboardInput(input = {}) {
39
+ const hasText = typeof input.text === "string" && input.text.length > 0;
40
+ const hasKey = typeof input.key === "string" && input.key.trim().length > 0;
41
+
42
+ if (hasText === hasKey) return { ok: false, error: "invalid_keyboard_input" };
43
+
44
+ if (hasText) {
45
+ if (input.text.length > MAX_TEXT_LENGTH) return { ok: false, error: "text_too_large" };
46
+ if (input.ctrl || input.alt || input.shift || input.win) {
47
+ return { ok: false, error: "invalid_keyboard_input" };
48
+ }
49
+ return { ok: true, payload: { text: input.text } };
50
+ }
51
+
52
+ const key = normalizedKey(input.key);
53
+ const keyCode = KEY_CODES.get(key);
54
+ if (!keyCode) return { ok: false, error: "unsupported_key" };
55
+
56
+ return {
57
+ ok: true,
58
+ payload: {
59
+ key,
60
+ keyCode,
61
+ ctrl: input.ctrl === true,
62
+ alt: input.alt === true,
63
+ shift: input.shift === true,
64
+ win: input.win === true,
65
+ },
66
+ };
67
+ }
68
+
69
+ function compactStepActions(actions) {
70
+ const compacted = [];
71
+ for (const action of actions) {
72
+ const previous = compacted.at(-1);
73
+ if (action.type === "wait" && action.ms === 0) continue;
74
+ if (action.type === "text" && previous?.type === "text" &&
75
+ previous.text.length + action.text.length <= MAX_TEXT_LENGTH) {
76
+ previous.text += action.text;
77
+ continue;
78
+ }
79
+ if (action.type === "wait" && previous?.type === "wait" && previous.ms + action.ms <= 5000) {
80
+ previous.ms += action.ms;
81
+ continue;
82
+ }
83
+ compacted.push({ ...action });
84
+ }
85
+ return compacted.length > 0 ? compacted : actions.map((action) => ({ ...action }));
86
+ }
87
+
88
+ function adaptiveActionSettleMs(action, nextAction) {
89
+ if (!nextAction || action.type === "wait") return 0;
90
+ if (action.type === "click") {
91
+ if (action.clicks === 2) return 45;
92
+ return nextAction.type === "text" || nextAction.type === "key" ? 25 : 15;
93
+ }
94
+ if (action.type === "text") return 0;
95
+ if (action.type === "key") {
96
+ if ((action.alt && action.key === "tab") || action.win) return 120;
97
+ if (["enter", "tab", "escape"].includes(action.key)) return 25;
98
+ return 5;
99
+ }
100
+ return 0;
101
+ }
102
+
103
+ function adaptiveFinalSettleMs(actions, wantsCapture) {
104
+ if (!wantsCapture || actions.length === 0) return 0;
105
+ const action = actions.at(-1);
106
+ if (action.type === "wait") return 0;
107
+ if (action.type === "click") return action.clicks === 2 ? 60 : 35;
108
+ if (action.type === "text") return 10;
109
+ if (action.type === "key") {
110
+ if ((action.alt && action.key === "tab") || action.win) return 180;
111
+ if (["enter", "tab", "escape"].includes(action.key)) return 50;
112
+ return 15;
113
+ }
114
+ return 0;
115
+ }
116
+
117
+ export function createDesktopPlatformAdapter(options = {}) {
118
+ const platform = options.platform ?? process.platform;
119
+ const customRunner = typeof options.runner === "function" ? options.runner : null;
120
+
121
+ if (platform === "win32") {
122
+ const persistentRunner = customRunner ? null : (options.persistentRunner ?? new PersistentDesktopRunner({ platform }));
123
+ return {
124
+ platform,
125
+ supported: true,
126
+ transport: "persistent-worker",
127
+ run: customRunner ?? ((operation, args) => persistentRunner.run(operation, args)),
128
+ close: () => persistentRunner?.close?.(),
129
+ };
130
+ }
131
+
132
+ if (platform === "darwin") {
133
+ const macosRunner = customRunner ? null : (options.macosRunner ?? new MacOSDesktopRunner({ platform }));
134
+ return {
135
+ platform,
136
+ supported: true,
137
+ transport: "native-macos",
138
+ run: customRunner ?? ((operation, args) => macosRunner.run(operation, args)),
139
+ close: () => macosRunner?.close?.(),
140
+ };
141
+ }
142
+
143
+ return {
144
+ platform,
145
+ supported: false,
146
+ transport: "unsupported",
147
+ run: customRunner ?? (async () => ({ ok: false, error: "unsupported_platform" })),
148
+ close: () => {},
149
+ };
150
+ }
151
+
152
+ export class DesktopManager {
153
+ constructor(options = {}) {
154
+ this.enabled = options.enabled ?? process.env.DESKTOP_ENABLED === "1";
155
+ this.adapter = options.adapter ?? createDesktopPlatformAdapter({
156
+ platform: options.platform,
157
+ persistentRunner: options.persistentRunner,
158
+ macosRunner: options.macosRunner,
159
+ runner: options.runner,
160
+ });
161
+ this.platform = this.adapter.platform;
162
+ this.runner = (operation, args) => this.adapter.run(operation, args);
163
+ this.controlLane = options.controlLane ?? new BoundedLane({
164
+ name: "desktop_control",
165
+ concurrency: 1,
166
+ maxQueued: options.controlMaxQueued ?? process.env.DESKTOP_CONTROL_MAX_QUEUED,
167
+ queueTimeoutMs: options.controlQueueTimeoutMs ?? process.env.AGENT_QUEUE_TIMEOUT_MS,
168
+ });
169
+ }
170
+
171
+ getConfig() {
172
+ return {
173
+ enabled: this.enabled,
174
+ supported: this.adapter.supported,
175
+ platform: this.adapter.platform,
176
+ transport: this.adapter.transport,
177
+ controlQueue: this.controlLane.snapshot(),
178
+ };
179
+ }
180
+
181
+ gate() {
182
+ if (!this.enabled) return { ok: false, error: "desktop_disabled" };
183
+ if (!this.adapter.supported) return { ok: false, error: "unsupported_platform" };
184
+ return null;
185
+ }
186
+
187
+ async runControl(work) {
188
+ try {
189
+ return await this.controlLane.run(work);
190
+ } catch (error) {
191
+ return { ok: false, error: error instanceof Error ? error.message : "desktop_queue_error" };
192
+ }
193
+ }
194
+
195
+ async screenshot(input = {}) {
196
+ const gate = this.gate();
197
+ if (gate) return gate;
198
+
199
+ const monitor = input.monitor ?? "primary";
200
+ if (!(monitor === "primary" || monitor === "secondary" || (Number.isInteger(monitor) && monitor >= 0 && monitor <= 15))) {
201
+ return { ok: false, error: "invalid_monitor" };
202
+ }
203
+
204
+ const maxWidth = input.maxWidth === undefined ? 960 : Number(input.maxWidth);
205
+ if (!Number.isInteger(maxWidth) || maxWidth < MIN_SCREENSHOT_WIDTH || maxWidth > MAX_SCREENSHOT_WIDTH) {
206
+ return { ok: false, error: "invalid_max_width" };
207
+ }
208
+ const quality = input.quality === undefined ? 58 : Number(input.quality);
209
+ if (!Number.isInteger(quality) || quality < MIN_SCREENSHOT_QUALITY || quality > MAX_SCREENSHOT_QUALITY) {
210
+ return { ok: false, error: "invalid_quality" };
211
+ }
212
+
213
+ const result = await this.runner("screenshot", { monitor, maxWidth, quality });
214
+ return this.normalizeScreenshotResult(result);
215
+ }
216
+
217
+ normalizeScreenshotResult(result) {
218
+ if (!result?.ok) return result || { ok: false, error: "capture_failed" };
219
+ if (result.mimeType !== "image/jpeg" || typeof result.data !== "string") {
220
+ return { ok: false, error: "capture_failed" };
221
+ }
222
+
223
+ const binaryBytes = Buffer.from(result.data, "base64").byteLength;
224
+ if (binaryBytes <= 0 || binaryBytes > MAX_SCREENSHOT_BINARY_BYTES) {
225
+ return { ok: false, error: "screenshot_too_large" };
226
+ }
227
+
228
+ return {
229
+ ...result,
230
+ ok: true,
231
+ width: Number(result.width),
232
+ height: Number(result.height),
233
+ desktopOriginX: Number(result.desktopOriginX),
234
+ desktopOriginY: Number(result.desktopOriginY),
235
+ desktopWidth: Number(result.desktopWidth),
236
+ desktopHeight: Number(result.desktopHeight),
237
+ scaleX: Number(result.scaleX),
238
+ scaleY: Number(result.scaleY),
239
+ monitorIndex: Number(result.monitorIndex),
240
+ isPrimary: result.isPrimary === true,
241
+ deviceName: typeof result.deviceName === "string" ? result.deviceName : undefined,
242
+ virtualDesktopOriginX: Number(result.virtualDesktopOriginX),
243
+ virtualDesktopOriginY: Number(result.virtualDesktopOriginY),
244
+ virtualDesktopWidth: Number(result.virtualDesktopWidth),
245
+ virtualDesktopHeight: Number(result.virtualDesktopHeight),
246
+ monitorCount: Number(result.monitorCount),
247
+ jpegQuality: Number(result.jpegQuality),
248
+ byteLength: binaryBytes,
249
+ };
250
+ }
251
+
252
+ async clipboardRead() {
253
+ const gate = this.gate();
254
+ if (gate) return gate;
255
+ const result = await this.runner("clipboard_read", {});
256
+ if (!result?.ok || typeof result.text !== "string") return result || { ok: false, error: "clipboard_failed" };
257
+ return { ok: true, text: result.text.slice(0, MAX_CLIPBOARD_TEXT_LENGTH), truncated: result.truncated === true };
258
+ }
259
+
260
+ async clipboardWrite(input = {}) {
261
+ const gate = this.gate();
262
+ if (gate) return gate;
263
+ if (typeof input.text !== "string") return { ok: false, error: "invalid_clipboard_text" };
264
+ if (input.text.length > MAX_CLIPBOARD_TEXT_LENGTH) return { ok: false, error: "text_too_large" };
265
+ return this.runControl(() => this.runner("clipboard_write", { text: input.text }));
266
+ }
267
+
268
+ async listWindows(input = {}) {
269
+ const gate = this.gate();
270
+ if (gate) return gate;
271
+ const limit = input.limit === undefined ? 50 : Number(input.limit);
272
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_WINDOW_LIST_LIMIT) return { ok: false, error: "invalid_window_limit" };
273
+ const result = await this.runner("window_list", { limit });
274
+ if (!result?.ok || !Array.isArray(result.windows)) return result || { ok: false, error: "window_list_failed" };
275
+ const windows = result.windows.slice(0, limit).map((window) => ({
276
+ windowId: String(window.windowId ?? window.WindowId ?? ""),
277
+ processId: Number(window.processId ?? window.ProcessId),
278
+ title: String(window.title ?? window.Title ?? ""),
279
+ x: Number(window.x ?? window.X),
280
+ y: Number(window.y ?? window.Y),
281
+ width: Number(window.width ?? window.Width),
282
+ height: Number(window.height ?? window.Height),
283
+ isForeground: (window.isForeground ?? window.IsForeground) === true,
284
+ }));
285
+ return { ok: true, windows, count: Math.min(Number(result.count ?? result.Count) || windows.length, limit) };
286
+ }
287
+
288
+ async focusWindow(input = {}) {
289
+ const gate = this.gate();
290
+ if (gate) return gate;
291
+ const windowId = String(input.windowId ?? "").trim();
292
+ if (!/^[1-9][0-9]{0,19}$/.test(windowId)) return { ok: false, error: "invalid_window_id" };
293
+ return this.runControl(() => this.runner("window_focus", { windowId }));
294
+ }
295
+
296
+ async mouseClick(input = {}) {
297
+ const gate = this.gate();
298
+ if (gate) return gate;
299
+
300
+ const x = Number(input.x);
301
+ const y = Number(input.y);
302
+ const button = String(input.button || "left").toLowerCase();
303
+ const clicks = Number(input.clicks ?? 1);
304
+
305
+ if (!Number.isInteger(x) || !Number.isInteger(y)) return { ok: false, error: "invalid_coordinates" };
306
+ if (!["left", "right", "middle"].includes(button)) return { ok: false, error: "invalid_button" };
307
+ if (clicks !== 1 && clicks !== 2) return { ok: false, error: "invalid_click_count" };
308
+
309
+ return this.runControl(() => this.runner("mouse_click", { x, y, button, clicks }));
310
+ }
311
+
312
+ async keyboardInput(input = {}) {
313
+ const gate = this.gate();
314
+ if (gate) return gate;
315
+
316
+ const normalized = normalizeKeyboardInput(input);
317
+ if (!normalized.ok) return normalized;
318
+ return this.runControl(() => this.runner("keyboard_input", normalized.payload));
319
+ }
320
+
321
+ async step(input = {}) {
322
+ const gate = this.gate();
323
+ if (gate) return gate;
324
+
325
+ const actions = Array.isArray(input.actions) ? input.actions : [];
326
+ if (actions.length < 1 || actions.length > 20) return { ok: false, error: "invalid_step_actions" };
327
+
328
+ const normalizedActions = [];
329
+ for (const action of actions) {
330
+ if (!action || typeof action !== "object") return { ok: false, error: "invalid_step_action" };
331
+ if (action.type === "click") {
332
+ const x = Number(action.x);
333
+ const y = Number(action.y);
334
+ const button = String(action.button || "left").toLowerCase();
335
+ const clicks = Number(action.clicks ?? 1);
336
+ if (!Number.isInteger(x) || !Number.isInteger(y)) return { ok: false, error: "invalid_coordinates" };
337
+ if (!["left", "right", "middle"].includes(button)) return { ok: false, error: "invalid_button" };
338
+ if (clicks !== 1 && clicks !== 2) return { ok: false, error: "invalid_click_count" };
339
+ normalizedActions.push({ type: "click", x, y, button, clicks });
340
+ continue;
341
+ }
342
+
343
+ if (action.type === "text" || action.type === "key") {
344
+ const normalized = normalizeKeyboardInput(action);
345
+ if (!normalized.ok) return normalized;
346
+ normalizedActions.push({ type: action.type, ...normalized.payload });
347
+ continue;
348
+ }
349
+
350
+ if (action.type === "wait") {
351
+ const ms = Number(action.ms);
352
+ if (!Number.isInteger(ms) || ms < 0 || ms > 5000) return { ok: false, error: "invalid_wait" };
353
+ normalizedActions.push({ type: "wait", ms });
354
+ continue;
355
+ }
356
+
357
+ return { ok: false, error: "invalid_step_action" };
358
+ }
359
+
360
+ const compactedActions = compactStepActions(normalizedActions);
361
+ const optimizedActions = compactedActions.map((action, index) => {
362
+ const settleAfterMs = adaptiveActionSettleMs(action, compactedActions[index + 1]);
363
+ return settleAfterMs > 0 ? { ...action, settleAfterMs } : action;
364
+ });
365
+
366
+ const monitor = input.monitor ?? "primary";
367
+ if (!(monitor === "primary" || monitor === "secondary" || (Number.isInteger(monitor) && monitor >= 0 && monitor <= 15))) {
368
+ return { ok: false, error: "invalid_monitor" };
369
+ }
370
+
371
+ const wantsCapture = input.captureAfter === true;
372
+ const settleMs = input.settleMs === undefined
373
+ ? adaptiveFinalSettleMs(compactedActions, wantsCapture)
374
+ : Number(input.settleMs);
375
+ if (!Number.isInteger(settleMs) || settleMs < 0 || settleMs > 5000) return { ok: false, error: "invalid_settle_ms" };
376
+
377
+ const maxWidth = input.maxWidth === undefined ? 960 : Number(input.maxWidth);
378
+ const quality = input.quality === undefined ? 58 : Number(input.quality);
379
+ if (wantsCapture && (!Number.isInteger(maxWidth) || maxWidth < MIN_SCREENSHOT_WIDTH || maxWidth > MAX_SCREENSHOT_WIDTH)) {
380
+ return { ok: false, error: "invalid_max_width" };
381
+ }
382
+ if (wantsCapture && (!Number.isInteger(quality) || quality < MIN_SCREENSHOT_QUALITY || quality > MAX_SCREENSHOT_QUALITY)) {
383
+ return { ok: false, error: "invalid_quality" };
384
+ }
385
+
386
+ const managerStartedAt = Date.now();
387
+ const result = await this.runControl(async (queueMeta = {}) => {
388
+ const handlerStartedAt = Date.now();
389
+ const workerResult = await this.runner("step", {
390
+ actions: optimizedActions,
391
+ captureAfter: wantsCapture,
392
+ settleMs,
393
+ monitor,
394
+ ...(wantsCapture ? { maxWidth, quality } : {}),
395
+ });
396
+ if (!workerResult || typeof workerResult !== "object") return workerResult;
397
+ return {
398
+ ...workerResult,
399
+ timing: {
400
+ ...(workerResult.timing && typeof workerResult.timing === "object" ? workerResult.timing : {}),
401
+ queueWaitMs: Math.max(0, Number(queueMeta.queueWaitMs) || 0),
402
+ managerMs: Math.max(0, Date.now() - handlerStartedAt),
403
+ requestedActions: normalizedActions.length,
404
+ executedActions: optimizedActions.length,
405
+ },
406
+ };
407
+ });
408
+ if (result && typeof result === "object") {
409
+ result.timing = {
410
+ ...(result.timing && typeof result.timing === "object" ? result.timing : {}),
411
+ totalMs: Math.max(0, Date.now() - managerStartedAt),
412
+ };
413
+ }
414
+
415
+ if (!wantsCapture) return result;
416
+ return this.normalizeScreenshotResult(result);
417
+ }
418
+
419
+ close() {
420
+ this.adapter.close();
421
+ }
422
+ }
423
+
424
+ export { KEY_CODES, MAX_SCREENSHOT_BINARY_BYTES, PersistentDesktopRunner, MacOSDesktopRunner };