@anusornneal/chat-relay 0.7.1 → 0.8.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,11 +1,20 @@
1
- import { execFile } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
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";
4
7
 
5
8
  const MAX_SCREENSHOT_BINARY_BYTES = 32 * 1024;
6
9
  const POWERSHELL_TIMEOUT_MS = 15_000;
7
10
  const MAX_TEXT_LENGTH = 8192;
8
- const helperPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "windows-desktop.ps1");
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");
9
18
 
10
19
  const KEY_CODES = new Map([
11
20
  ["enter", 0x0D],
@@ -31,69 +40,203 @@ function normalizedKey(value) {
31
40
  return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
32
41
  }
33
42
 
34
- function encodeArgs(args) {
35
- return Buffer.from(JSON.stringify(args || {}), "utf8").toString("base64");
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
+ }
36
172
  }
37
173
 
38
- async function defaultRunner(operation, args) {
39
- return new Promise((resolve) => {
40
- execFile(
41
- "powershell.exe",
42
- [
43
- "-NoLogo",
44
- "-NoProfile",
45
- "-NonInteractive",
46
- "-ExecutionPolicy",
47
- "Bypass",
48
- "-File",
49
- helperPath,
50
- "-Operation",
51
- operation,
52
- "-InputBase64",
53
- encodeArgs(args),
54
- ],
55
- {
56
- windowsHide: true,
57
- timeout: POWERSHELL_TIMEOUT_MS,
58
- maxBuffer: 512 * 1024,
59
- },
60
- (error, stdout) => {
61
- const line = String(stdout || "").trim().split(/\r?\n/).filter(Boolean).at(-1);
62
- if (!line) {
63
- resolve({ ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" });
64
- return;
65
- }
66
- try {
67
- resolve(JSON.parse(line));
68
- } catch {
69
- resolve({ ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" });
70
- }
71
- },
72
- );
73
- });
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
+ };
74
196
  }
75
197
 
76
198
  export class DesktopManager {
77
199
  constructor(options = {}) {
78
200
  this.enabled = options.enabled ?? process.env.DESKTOP_ENABLED === "1";
79
- this.platform = options.platform ?? process.platform;
80
- this.runner = options.runner ?? defaultRunner;
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
+ });
81
214
  }
82
215
 
83
216
  getConfig() {
84
217
  return {
85
218
  enabled: this.enabled,
86
- supported: this.platform === "win32",
87
- platform: this.platform,
219
+ supported: this.adapter.supported,
220
+ platform: this.adapter.platform,
221
+ transport: this.adapter.transport,
222
+ controlQueue: this.controlLane.snapshot(),
88
223
  };
89
224
  }
90
225
 
91
226
  gate() {
92
227
  if (!this.enabled) return { ok: false, error: "desktop_disabled" };
93
- if (this.platform !== "win32") return { ok: false, error: "unsupported_platform" };
228
+ if (!this.adapter.supported) return { ok: false, error: "unsupported_platform" };
94
229
  return null;
95
230
  }
96
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
+
97
240
  async screenshot(input = {}) {
98
241
  const gate = this.gate();
99
242
  if (gate) return gate;
@@ -103,7 +246,20 @@ export class DesktopManager {
103
246
  return { ok: false, error: "invalid_monitor" };
104
247
  }
105
248
 
106
- const result = await this.runner("screenshot", { monitor });
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) {
107
263
  if (!result?.ok) return result || { ok: false, error: "capture_failed" };
108
264
  if (result.mimeType !== "image/jpeg" || typeof result.data !== "string") {
109
265
  return { ok: false, error: "capture_failed" };
@@ -115,9 +271,8 @@ export class DesktopManager {
115
271
  }
116
272
 
117
273
  return {
274
+ ...result,
118
275
  ok: true,
119
- mimeType: result.mimeType,
120
- data: result.data,
121
276
  width: Number(result.width),
122
277
  height: Number(result.height),
123
278
  desktopOriginX: Number(result.desktopOriginX),
@@ -129,10 +284,60 @@ export class DesktopManager {
129
284
  monitorIndex: Number(result.monitorIndex),
130
285
  isPrimary: result.isPrimary === true,
131
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),
132
293
  byteLength: binaryBytes,
133
294
  };
134
295
  }
135
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
+
136
341
  async mouseClick(input = {}) {
137
342
  const gate = this.gate();
138
343
  if (gate) return gate;
@@ -146,38 +351,90 @@ export class DesktopManager {
146
351
  if (!["left", "right", "middle"].includes(button)) return { ok: false, error: "invalid_button" };
147
352
  if (clicks !== 1 && clicks !== 2) return { ok: false, error: "invalid_click_count" };
148
353
 
149
- return this.runner("mouse_click", { x, y, button, clicks });
354
+ return this.runControl(() => this.runner("mouse_click", { x, y, button, clicks }));
150
355
  }
151
356
 
152
357
  async keyboardInput(input = {}) {
153
358
  const gate = this.gate();
154
359
  if (gate) return gate;
155
360
 
156
- const hasText = typeof input.text === "string" && input.text.length > 0;
157
- const hasKey = typeof input.key === "string" && input.key.trim().length > 0;
361
+ const normalized = normalizeKeyboardInput(input);
362
+ if (!normalized.ok) return normalized;
363
+ return this.runControl(() => this.runner("keyboard_input", normalized.payload));
364
+ }
158
365
 
159
- if (hasText === hasKey) return { ok: false, error: "invalid_keyboard_input" };
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
+ }
160
394
 
161
- if (hasText) {
162
- if (input.text.length > MAX_TEXT_LENGTH) return { ok: false, error: "text_too_large" };
163
- if (input.ctrl || input.alt || input.shift || input.win) {
164
- return { ok: false, error: "invalid_keyboard_input" };
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;
165
400
  }
166
- return this.runner("keyboard_input", { text: input.text });
401
+
402
+ return { ok: false, error: "invalid_step_action" };
167
403
  }
168
404
 
169
- const key = normalizedKey(input.key);
170
- const keyCode = KEY_CODES.get(key);
171
- if (!keyCode) return { ok: false, error: "unsupported_key" };
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
+ }
172
409
 
173
- return this.runner("keyboard_input", {
174
- keyCode,
175
- ctrl: input.ctrl === true,
176
- alt: input.alt === true,
177
- shift: input.shift === true,
178
- win: input.win === true,
179
- });
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();
180
437
  }
181
438
  }
182
439
 
183
- export { KEY_CODES, MAX_SCREENSHOT_BINARY_BYTES };
440
+ export { KEY_CODES, MAX_SCREENSHOT_BINARY_BYTES, PersistentDesktopRunner };