@anusornneal/chat-relay 0.7.2 → 0.8.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.
@@ -1,13 +1,15 @@
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";
1
+ import { BoundedLane } from "./capability-scheduler.mjs";
2
+ import { PersistentDesktopRunner } from "./windows-desktop-runner.mjs";
3
+ import { MacOSDesktopRunner } from "./macos-desktop-runner.mjs";
6
4
 
7
5
  const MAX_SCREENSHOT_BINARY_BYTES = 32 * 1024;
8
- const POWERSHELL_TIMEOUT_MS = 15_000;
9
6
  const MAX_TEXT_LENGTH = 8192;
10
- const helperPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "windows-desktop-worker.ps1");
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;
11
13
 
12
14
  const KEY_CODES = new Map([
13
15
  ["enter", 0x0D],
@@ -54,6 +56,7 @@ function normalizeKeyboardInput(input = {}) {
54
56
  return {
55
57
  ok: true,
56
58
  payload: {
59
+ key,
57
60
  keyCode,
58
61
  ctrl: input.ctrl === true,
59
62
  alt: input.alt === true,
@@ -63,129 +66,132 @@ function normalizeKeyboardInput(input = {}) {
63
66
  };
64
67
  }
65
68
 
66
- class PersistentDesktopRunner {
67
- constructor(options = {}) {
68
- this.platform = options.platform ?? process.platform;
69
- this.timeoutMs = Number(options.timeoutMs ?? POWERSHELL_TIMEOUT_MS);
70
- this.process = null;
71
- this.stdout = null;
72
- this.pending = new Map();
73
- this.stderr = "";
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 });
74
84
  }
85
+ return compacted.length > 0 ? compacted : actions.map((action) => ({ ...action }));
86
+ }
75
87
 
76
- ensureStarted() {
77
- if (this.platform !== "win32") return false;
78
- if (this.process && !this.process.killed) return true;
79
-
80
- const child = spawn("powershell.exe", [
81
- "-NoLogo",
82
- "-NoProfile",
83
- "-NonInteractive",
84
- "-ExecutionPolicy",
85
- "Bypass",
86
- "-File",
87
- helperPath,
88
- ], {
89
- windowsHide: true,
90
- stdio: ["pipe", "pipe", "pipe"],
91
- });
92
-
93
- this.process = child;
94
- this.stderr = "";
95
- this.stdout = readline.createInterface({ input: child.stdout });
96
-
97
- this.stdout.on("line", (line) => {
98
- let message;
99
- try {
100
- message = JSON.parse(line);
101
- } catch {
102
- return;
103
- }
104
- const pending = this.pending.get(message?.id);
105
- if (!pending) return;
106
- clearTimeout(pending.timer);
107
- this.pending.delete(message.id);
108
- pending.resolve(message.result ?? { ok: false, error: "desktop_worker_error" });
109
- });
110
-
111
- child.stderr.on("data", (chunk) => {
112
- this.stderr = (this.stderr + String(chunk)).slice(-8192);
113
- });
114
-
115
- child.on("exit", () => {
116
- const error = { ok: false, error: "desktop_worker_exited" };
117
- for (const pending of this.pending.values()) {
118
- clearTimeout(pending.timer);
119
- pending.resolve(error);
120
- }
121
- this.pending.clear();
122
- this.stdout?.close();
123
- this.stdout = null;
124
- this.process = null;
125
- });
126
-
127
- child.on("error", () => {
128
- // exit handler settles pending work.
129
- });
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
+ }
130
102
 
131
- return true;
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;
132
113
  }
114
+ return 0;
115
+ }
133
116
 
134
- async run(operation, args) {
135
- if (!this.ensureStarted() || !this.process?.stdin?.writable) {
136
- return { ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" };
137
- }
117
+ export function createDesktopPlatformAdapter(options = {}) {
118
+ const platform = options.platform ?? process.platform;
119
+ const customRunner = typeof options.runner === "function" ? options.runner : null;
138
120
 
139
- const id = randomUUID();
140
- const request = JSON.stringify({ id, operation, args: args || {} }) + "\n";
141
-
142
- return new Promise((resolve) => {
143
- const timer = setTimeout(() => {
144
- this.pending.delete(id);
145
- resolve({ ok: false, error: operation === "screenshot" ? "capture_timeout" : "input_timeout" });
146
- }, this.timeoutMs);
147
-
148
- this.pending.set(id, { resolve, timer });
149
- this.process.stdin.write(request, (error) => {
150
- if (!error) return;
151
- clearTimeout(timer);
152
- this.pending.delete(id);
153
- resolve({ ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" });
154
- });
155
- });
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
+ };
156
130
  }
157
131
 
158
- close() {
159
- try { this.stdout?.close(); } catch {}
160
- try { this.process?.kill(); } catch {}
161
- this.stdout = null;
162
- this.process = null;
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
+ };
163
141
  }
142
+
143
+ return {
144
+ platform,
145
+ supported: false,
146
+ transport: "unsupported",
147
+ run: customRunner ?? (async () => ({ ok: false, error: "unsupported_platform" })),
148
+ close: () => {},
149
+ };
164
150
  }
165
151
 
166
152
  export class DesktopManager {
167
153
  constructor(options = {}) {
168
154
  this.enabled = options.enabled ?? process.env.DESKTOP_ENABLED === "1";
169
- this.platform = options.platform ?? process.platform;
170
- this.persistentRunner = options.persistentRunner ?? new PersistentDesktopRunner({ platform: this.platform });
171
- this.runner = options.runner ?? ((operation, args) => this.persistentRunner.run(operation, args));
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
+ });
172
169
  }
173
170
 
174
171
  getConfig() {
175
172
  return {
176
173
  enabled: this.enabled,
177
- supported: this.platform === "win32",
178
- platform: this.platform,
179
- transport: this.platform === "win32" ? "persistent-worker" : "unsupported",
174
+ supported: this.adapter.supported,
175
+ platform: this.adapter.platform,
176
+ transport: this.adapter.transport,
177
+ controlQueue: this.controlLane.snapshot(),
180
178
  };
181
179
  }
182
180
 
183
181
  gate() {
184
182
  if (!this.enabled) return { ok: false, error: "desktop_disabled" };
185
- if (this.platform !== "win32") return { ok: false, error: "unsupported_platform" };
183
+ if (!this.adapter.supported) return { ok: false, error: "unsupported_platform" };
186
184
  return null;
187
185
  }
188
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
+
189
195
  async screenshot(input = {}) {
190
196
  const gate = this.gate();
191
197
  if (gate) return gate;
@@ -195,7 +201,16 @@ export class DesktopManager {
195
201
  return { ok: false, error: "invalid_monitor" };
196
202
  }
197
203
 
198
- const result = await this.runner("screenshot", { monitor });
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 });
199
214
  return this.normalizeScreenshotResult(result);
200
215
  }
201
216
 
@@ -224,10 +239,60 @@ export class DesktopManager {
224
239
  monitorIndex: Number(result.monitorIndex),
225
240
  isPrimary: result.isPrimary === true,
226
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),
227
248
  byteLength: binaryBytes,
228
249
  };
229
250
  }
230
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
+
231
296
  async mouseClick(input = {}) {
232
297
  const gate = this.gate();
233
298
  if (gate) return gate;
@@ -241,7 +306,7 @@ export class DesktopManager {
241
306
  if (!["left", "right", "middle"].includes(button)) return { ok: false, error: "invalid_button" };
242
307
  if (clicks !== 1 && clicks !== 2) return { ok: false, error: "invalid_click_count" };
243
308
 
244
- return this.runner("mouse_click", { x, y, button, clicks });
309
+ return this.runControl(() => this.runner("mouse_click", { x, y, button, clicks }));
245
310
  }
246
311
 
247
312
  async keyboardInput(input = {}) {
@@ -250,7 +315,7 @@ export class DesktopManager {
250
315
 
251
316
  const normalized = normalizeKeyboardInput(input);
252
317
  if (!normalized.ok) return normalized;
253
- return this.runner("keyboard_input", normalized.payload);
318
+ return this.runControl(() => this.runner("keyboard_input", normalized.payload));
254
319
  }
255
320
 
256
321
  async step(input = {}) {
@@ -292,28 +357,68 @@ export class DesktopManager {
292
357
  return { ok: false, error: "invalid_step_action" };
293
358
  }
294
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
+
295
366
  const monitor = input.monitor ?? "primary";
296
367
  if (!(monitor === "primary" || monitor === "secondary" || (Number.isInteger(monitor) && monitor >= 0 && monitor <= 15))) {
297
368
  return { ok: false, error: "invalid_monitor" };
298
369
  }
299
370
 
300
- const settleMs = input.settleMs === undefined ? 120 : Number(input.settleMs);
371
+ const wantsCapture = input.captureAfter === true;
372
+ const settleMs = input.settleMs === undefined
373
+ ? adaptiveFinalSettleMs(compactedActions, wantsCapture)
374
+ : Number(input.settleMs);
301
375
  if (!Number.isInteger(settleMs) || settleMs < 0 || settleMs > 5000) return { ok: false, error: "invalid_settle_ms" };
302
376
 
303
- const result = await this.runner("step", {
304
- actions: normalizedActions,
305
- captureAfter: input.captureAfter !== false,
306
- settleMs,
307
- monitor,
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
+ };
308
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
+ }
309
414
 
310
- if (input.captureAfter === false) return result;
415
+ if (!wantsCapture) return result;
311
416
  return this.normalizeScreenshotResult(result);
312
417
  }
313
418
 
314
419
  close() {
315
- this.persistentRunner?.close?.();
420
+ this.adapter.close();
316
421
  }
317
422
  }
318
423
 
319
- export { KEY_CODES, MAX_SCREENSHOT_BINARY_BYTES, PersistentDesktopRunner };
424
+ export { KEY_CODES, MAX_SCREENSHOT_BINARY_BYTES, PersistentDesktopRunner, MacOSDesktopRunner };