@anusornneal/chat-relay 0.7.1 → 0.7.2

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,183 +1,319 @@
1
- import { execFile } from "node:child_process";
2
- import { fileURLToPath } from "node:url";
3
- import path from "node:path";
4
-
5
- const MAX_SCREENSHOT_BINARY_BYTES = 32 * 1024;
6
- const POWERSHELL_TIMEOUT_MS = 15_000;
7
- const MAX_TEXT_LENGTH = 8192;
8
- const helperPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "windows-desktop.ps1");
9
-
10
- const KEY_CODES = new Map([
11
- ["enter", 0x0D],
12
- ["tab", 0x09],
13
- ["escape", 0x1B],
14
- ["backspace", 0x08],
15
- ["delete", 0x2E],
16
- ["arrowleft", 0x25],
17
- ["arrowup", 0x26],
18
- ["arrowright", 0x27],
19
- ["arrowdown", 0x28],
20
- ["home", 0x24],
21
- ["end", 0x23],
22
- ["pageup", 0x21],
23
- ["pagedown", 0x22],
24
- ["space", 0x20],
25
- ...Array.from({ length: 12 }, (_, index) => ["f" + (index + 1), 0x70 + index]),
26
- ...Array.from({ length: 26 }, (_, index) => [String.fromCharCode(97 + index), 0x41 + index]),
27
- ...Array.from({ length: 10 }, (_, index) => [String(index), 0x30 + index]),
28
- ]);
29
-
30
- function normalizedKey(value) {
31
- return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
32
- }
33
-
34
- function encodeArgs(args) {
35
- return Buffer.from(JSON.stringify(args || {}), "utf8").toString("base64");
36
- }
37
-
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
- });
74
- }
75
-
76
- export class DesktopManager {
77
- constructor(options = {}) {
78
- this.enabled = options.enabled ?? process.env.DESKTOP_ENABLED === "1";
79
- this.platform = options.platform ?? process.platform;
80
- this.runner = options.runner ?? defaultRunner;
81
- }
82
-
83
- getConfig() {
84
- return {
85
- enabled: this.enabled,
86
- supported: this.platform === "win32",
87
- platform: this.platform,
88
- };
89
- }
90
-
91
- gate() {
92
- if (!this.enabled) return { ok: false, error: "desktop_disabled" };
93
- if (this.platform !== "win32") return { ok: false, error: "unsupported_platform" };
94
- return null;
95
- }
96
-
97
- async screenshot(input = {}) {
98
- const gate = this.gate();
99
- if (gate) return gate;
100
-
101
- const monitor = input.monitor ?? "primary";
102
- if (!(monitor === "primary" || monitor === "secondary" || (Number.isInteger(monitor) && monitor >= 0 && monitor <= 15))) {
103
- return { ok: false, error: "invalid_monitor" };
104
- }
105
-
106
- const result = await this.runner("screenshot", { monitor });
107
- if (!result?.ok) return result || { ok: false, error: "capture_failed" };
108
- if (result.mimeType !== "image/jpeg" || typeof result.data !== "string") {
109
- return { ok: false, error: "capture_failed" };
110
- }
111
-
112
- const binaryBytes = Buffer.from(result.data, "base64").byteLength;
113
- if (binaryBytes <= 0 || binaryBytes > MAX_SCREENSHOT_BINARY_BYTES) {
114
- return { ok: false, error: "screenshot_too_large" };
115
- }
116
-
117
- return {
118
- ok: true,
119
- mimeType: result.mimeType,
120
- data: result.data,
121
- width: Number(result.width),
122
- height: Number(result.height),
123
- desktopOriginX: Number(result.desktopOriginX),
124
- desktopOriginY: Number(result.desktopOriginY),
125
- desktopWidth: Number(result.desktopWidth),
126
- desktopHeight: Number(result.desktopHeight),
127
- scaleX: Number(result.scaleX),
128
- scaleY: Number(result.scaleY),
129
- monitorIndex: Number(result.monitorIndex),
130
- isPrimary: result.isPrimary === true,
131
- deviceName: typeof result.deviceName === "string" ? result.deviceName : undefined,
132
- byteLength: binaryBytes,
133
- };
134
- }
135
-
136
- async mouseClick(input = {}) {
137
- const gate = this.gate();
138
- if (gate) return gate;
139
-
140
- const x = Number(input.x);
141
- const y = Number(input.y);
142
- const button = String(input.button || "left").toLowerCase();
143
- const clicks = Number(input.clicks ?? 1);
144
-
145
- if (!Number.isInteger(x) || !Number.isInteger(y)) return { ok: false, error: "invalid_coordinates" };
146
- if (!["left", "right", "middle"].includes(button)) return { ok: false, error: "invalid_button" };
147
- if (clicks !== 1 && clicks !== 2) return { ok: false, error: "invalid_click_count" };
148
-
149
- return this.runner("mouse_click", { x, y, button, clicks });
150
- }
151
-
152
- async keyboardInput(input = {}) {
153
- const gate = this.gate();
154
- if (gate) return gate;
155
-
156
- const hasText = typeof input.text === "string" && input.text.length > 0;
157
- const hasKey = typeof input.key === "string" && input.key.trim().length > 0;
158
-
159
- if (hasText === hasKey) return { ok: false, error: "invalid_keyboard_input" };
160
-
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" };
165
- }
166
- return this.runner("keyboard_input", { text: input.text });
167
- }
168
-
169
- const key = normalizedKey(input.key);
170
- const keyCode = KEY_CODES.get(key);
171
- if (!keyCode) return { ok: false, error: "unsupported_key" };
172
-
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
- });
180
- }
181
- }
182
-
183
- export { KEY_CODES, MAX_SCREENSHOT_BINARY_BYTES };
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
+
7
+ const MAX_SCREENSHOT_BINARY_BYTES = 32 * 1024;
8
+ const POWERSHELL_TIMEOUT_MS = 15_000;
9
+ const MAX_TEXT_LENGTH = 8192;
10
+ const helperPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "windows-desktop-worker.ps1");
11
+
12
+ const KEY_CODES = new Map([
13
+ ["enter", 0x0D],
14
+ ["tab", 0x09],
15
+ ["escape", 0x1B],
16
+ ["backspace", 0x08],
17
+ ["delete", 0x2E],
18
+ ["arrowleft", 0x25],
19
+ ["arrowup", 0x26],
20
+ ["arrowright", 0x27],
21
+ ["arrowdown", 0x28],
22
+ ["home", 0x24],
23
+ ["end", 0x23],
24
+ ["pageup", 0x21],
25
+ ["pagedown", 0x22],
26
+ ["space", 0x20],
27
+ ...Array.from({ length: 12 }, (_, index) => ["f" + (index + 1), 0x70 + index]),
28
+ ...Array.from({ length: 26 }, (_, index) => [String.fromCharCode(97 + index), 0x41 + index]),
29
+ ...Array.from({ length: 10 }, (_, index) => [String(index), 0x30 + index]),
30
+ ]);
31
+
32
+ function normalizedKey(value) {
33
+ return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
34
+ }
35
+
36
+ function normalizeKeyboardInput(input = {}) {
37
+ const hasText = typeof input.text === "string" && input.text.length > 0;
38
+ const hasKey = typeof input.key === "string" && input.key.trim().length > 0;
39
+
40
+ if (hasText === hasKey) return { ok: false, error: "invalid_keyboard_input" };
41
+
42
+ if (hasText) {
43
+ if (input.text.length > MAX_TEXT_LENGTH) return { ok: false, error: "text_too_large" };
44
+ if (input.ctrl || input.alt || input.shift || input.win) {
45
+ return { ok: false, error: "invalid_keyboard_input" };
46
+ }
47
+ return { ok: true, payload: { text: input.text } };
48
+ }
49
+
50
+ const key = normalizedKey(input.key);
51
+ const keyCode = KEY_CODES.get(key);
52
+ if (!keyCode) return { ok: false, error: "unsupported_key" };
53
+
54
+ return {
55
+ ok: true,
56
+ payload: {
57
+ keyCode,
58
+ ctrl: input.ctrl === true,
59
+ alt: input.alt === true,
60
+ shift: input.shift === true,
61
+ win: input.win === true,
62
+ },
63
+ };
64
+ }
65
+
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 = "";
74
+ }
75
+
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
+ });
130
+
131
+ return true;
132
+ }
133
+
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
+ }
138
+
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
+ });
156
+ }
157
+
158
+ close() {
159
+ try { this.stdout?.close(); } catch {}
160
+ try { this.process?.kill(); } catch {}
161
+ this.stdout = null;
162
+ this.process = null;
163
+ }
164
+ }
165
+
166
+ export class DesktopManager {
167
+ constructor(options = {}) {
168
+ 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));
172
+ }
173
+
174
+ getConfig() {
175
+ return {
176
+ enabled: this.enabled,
177
+ supported: this.platform === "win32",
178
+ platform: this.platform,
179
+ transport: this.platform === "win32" ? "persistent-worker" : "unsupported",
180
+ };
181
+ }
182
+
183
+ gate() {
184
+ if (!this.enabled) return { ok: false, error: "desktop_disabled" };
185
+ if (this.platform !== "win32") return { ok: false, error: "unsupported_platform" };
186
+ return null;
187
+ }
188
+
189
+ async screenshot(input = {}) {
190
+ const gate = this.gate();
191
+ if (gate) return gate;
192
+
193
+ const monitor = input.monitor ?? "primary";
194
+ if (!(monitor === "primary" || monitor === "secondary" || (Number.isInteger(monitor) && monitor >= 0 && monitor <= 15))) {
195
+ return { ok: false, error: "invalid_monitor" };
196
+ }
197
+
198
+ const result = await this.runner("screenshot", { monitor });
199
+ return this.normalizeScreenshotResult(result);
200
+ }
201
+
202
+ normalizeScreenshotResult(result) {
203
+ if (!result?.ok) return result || { ok: false, error: "capture_failed" };
204
+ if (result.mimeType !== "image/jpeg" || typeof result.data !== "string") {
205
+ return { ok: false, error: "capture_failed" };
206
+ }
207
+
208
+ const binaryBytes = Buffer.from(result.data, "base64").byteLength;
209
+ if (binaryBytes <= 0 || binaryBytes > MAX_SCREENSHOT_BINARY_BYTES) {
210
+ return { ok: false, error: "screenshot_too_large" };
211
+ }
212
+
213
+ return {
214
+ ...result,
215
+ ok: true,
216
+ width: Number(result.width),
217
+ height: Number(result.height),
218
+ desktopOriginX: Number(result.desktopOriginX),
219
+ desktopOriginY: Number(result.desktopOriginY),
220
+ desktopWidth: Number(result.desktopWidth),
221
+ desktopHeight: Number(result.desktopHeight),
222
+ scaleX: Number(result.scaleX),
223
+ scaleY: Number(result.scaleY),
224
+ monitorIndex: Number(result.monitorIndex),
225
+ isPrimary: result.isPrimary === true,
226
+ deviceName: typeof result.deviceName === "string" ? result.deviceName : undefined,
227
+ byteLength: binaryBytes,
228
+ };
229
+ }
230
+
231
+ async mouseClick(input = {}) {
232
+ const gate = this.gate();
233
+ if (gate) return gate;
234
+
235
+ const x = Number(input.x);
236
+ const y = Number(input.y);
237
+ const button = String(input.button || "left").toLowerCase();
238
+ const clicks = Number(input.clicks ?? 1);
239
+
240
+ if (!Number.isInteger(x) || !Number.isInteger(y)) return { ok: false, error: "invalid_coordinates" };
241
+ if (!["left", "right", "middle"].includes(button)) return { ok: false, error: "invalid_button" };
242
+ if (clicks !== 1 && clicks !== 2) return { ok: false, error: "invalid_click_count" };
243
+
244
+ return this.runner("mouse_click", { x, y, button, clicks });
245
+ }
246
+
247
+ async keyboardInput(input = {}) {
248
+ const gate = this.gate();
249
+ if (gate) return gate;
250
+
251
+ const normalized = normalizeKeyboardInput(input);
252
+ if (!normalized.ok) return normalized;
253
+ return this.runner("keyboard_input", normalized.payload);
254
+ }
255
+
256
+ async step(input = {}) {
257
+ const gate = this.gate();
258
+ if (gate) return gate;
259
+
260
+ const actions = Array.isArray(input.actions) ? input.actions : [];
261
+ if (actions.length < 1 || actions.length > 20) return { ok: false, error: "invalid_step_actions" };
262
+
263
+ const normalizedActions = [];
264
+ for (const action of actions) {
265
+ if (!action || typeof action !== "object") return { ok: false, error: "invalid_step_action" };
266
+ if (action.type === "click") {
267
+ const x = Number(action.x);
268
+ const y = Number(action.y);
269
+ const button = String(action.button || "left").toLowerCase();
270
+ const clicks = Number(action.clicks ?? 1);
271
+ if (!Number.isInteger(x) || !Number.isInteger(y)) return { ok: false, error: "invalid_coordinates" };
272
+ if (!["left", "right", "middle"].includes(button)) return { ok: false, error: "invalid_button" };
273
+ if (clicks !== 1 && clicks !== 2) return { ok: false, error: "invalid_click_count" };
274
+ normalizedActions.push({ type: "click", x, y, button, clicks });
275
+ continue;
276
+ }
277
+
278
+ if (action.type === "text" || action.type === "key") {
279
+ const normalized = normalizeKeyboardInput(action);
280
+ if (!normalized.ok) return normalized;
281
+ normalizedActions.push({ type: action.type, ...normalized.payload });
282
+ continue;
283
+ }
284
+
285
+ if (action.type === "wait") {
286
+ const ms = Number(action.ms);
287
+ if (!Number.isInteger(ms) || ms < 0 || ms > 5000) return { ok: false, error: "invalid_wait" };
288
+ normalizedActions.push({ type: "wait", ms });
289
+ continue;
290
+ }
291
+
292
+ return { ok: false, error: "invalid_step_action" };
293
+ }
294
+
295
+ const monitor = input.monitor ?? "primary";
296
+ if (!(monitor === "primary" || monitor === "secondary" || (Number.isInteger(monitor) && monitor >= 0 && monitor <= 15))) {
297
+ return { ok: false, error: "invalid_monitor" };
298
+ }
299
+
300
+ const settleMs = input.settleMs === undefined ? 120 : Number(input.settleMs);
301
+ if (!Number.isInteger(settleMs) || settleMs < 0 || settleMs > 5000) return { ok: false, error: "invalid_settle_ms" };
302
+
303
+ const result = await this.runner("step", {
304
+ actions: normalizedActions,
305
+ captureAfter: input.captureAfter !== false,
306
+ settleMs,
307
+ monitor,
308
+ });
309
+
310
+ if (input.captureAfter === false) return result;
311
+ return this.normalizeScreenshotResult(result);
312
+ }
313
+
314
+ close() {
315
+ this.persistentRunner?.close?.();
316
+ }
317
+ }
318
+
319
+ export { KEY_CODES, MAX_SCREENSHOT_BINARY_BYTES, PersistentDesktopRunner };
@@ -9,7 +9,15 @@ const MAX_READ_RESPONSE_BYTES = 48 * 1024;
9
9
  const MIN_READ_RESPONSE_BYTES = 1024;
10
10
  const MAX_WRITE_BYTES = 256 * 1024;
11
11
  const MAX_SEARCH_FILES = 5000;
12
+ const DEFAULT_LIST_LIMIT = 200;
13
+ const MAX_LIST_LIMIT = 500;
14
+ const DEFAULT_LIST_RESPONSE_BYTES = 48 * 1024;
12
15
  const SKIP_DIRS = new Set([".git", "node_modules", ".gradle", ".idea", ".wrangler"]);
16
+ const ALLOWED_DOT_DIRS = new Set([".github"]);
17
+
18
+ function skipWalkDirectory(name) {
19
+ return SKIP_DIRS.has(name) || (name.startsWith(".") && !ALLOWED_DOT_DIRS.has(name));
20
+ }
13
21
 
14
22
  function splitRoots(spec) {
15
23
  if (!spec) return [process.cwd()];
@@ -82,31 +90,37 @@ export class FileManager {
82
90
  };
83
91
  }
84
92
 
85
- async list(input, depth = 1) {
93
+ async list(input, depth = 1, offset = 0, limit = DEFAULT_LIST_LIMIT, maxBytes = DEFAULT_LIST_RESPONSE_BYTES) {
86
94
  const target = await this.#resolveExisting(input);
87
- const maxDepth = Math.min(Math.max(Number(depth) || 1, 0), 3);
95
+ const numericDepth = Number(depth);
96
+ const maxDepth = Number.isFinite(numericDepth) ? Math.min(Math.max(Math.trunc(numericDepth), 0), 3) : 1;
97
+ const start = Math.max(Math.trunc(Number(offset) || 0), 0);
98
+ const pageLimit = Math.min(Math.max(Math.trunc(Number(limit) || DEFAULT_LIST_LIMIT), 1), MAX_LIST_LIMIT);
99
+ const responseBudget = boundedReadBytes(maxBytes, DEFAULT_LIST_RESPONSE_BYTES, 4 * 1024);
88
100
  const entries = [];
89
- await this.#walkList(target, 0, maxDepth, entries);
90
- return { path: target, entries };
101
+ await this.#walkList(target, 0, maxDepth, entries, start + pageLimit + 1);
102
+
103
+ const selected = entries.slice(start, start + pageLimit);
104
+ while (selected.length > 1 && byteSize({ path: target, entries: selected, offset: start }) > responseBudget) selected.pop();
105
+ if (selected.length === 1 && byteSize({ path: target, entries: selected, offset: start }) > responseBudget) throw new Error("list_entry_too_large");
106
+
107
+ const nextOffset = start + selected.length;
108
+ const hasMore = nextOffset < entries.length;
109
+ return { path: target, entries: selected, offset: start, limit: pageLimit, maxBytes: responseBudget, truncated: hasMore, nextOffset: hasMore ? nextOffset : null };
91
110
  }
92
111
 
93
- async #walkList(current, level, maxDepth, output) {
112
+ async #walkList(current, level, maxDepth, output, stopAfter = 1001) {
94
113
  const items = await fs.readdir(current, { withFileTypes: true });
95
114
  items.sort((a, b) => a.name.localeCompare(b.name));
96
115
  for (const item of items) {
97
116
  const fullPath = path.join(current, item.name);
98
117
  let size = null;
99
118
  if (item.isFile()) size = (await fs.stat(fullPath)).size;
100
- output.push({
101
- path: fullPath,
102
- name: item.name,
103
- type: item.isDirectory() ? "directory" : item.isFile() ? "file" : "other",
104
- size,
105
- });
106
- if (output.length >= 1000) return;
107
- if (item.isDirectory() && level < maxDepth && !SKIP_DIRS.has(item.name)) {
108
- await this.#walkList(fullPath, level + 1, maxDepth, output);
109
- if (output.length >= 1000) return;
119
+ output.push({ path: fullPath, name: item.name, type: item.isDirectory() ? "directory" : item.isFile() ? "file" : "other", size });
120
+ if (output.length >= stopAfter) return;
121
+ if (item.isDirectory() && level < maxDepth && !skipWalkDirectory(item.name)) {
122
+ await this.#walkList(fullPath, level + 1, maxDepth, output, stopAfter);
123
+ if (output.length >= stopAfter) return;
110
124
  }
111
125
  }
112
126
  }
@@ -305,12 +319,14 @@ export class FileManager {
305
319
  const needle = pattern.toLowerCase();
306
320
  let scanned = 0;
307
321
 
308
- const walk = async (current) => {
309
- if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) return;
322
+ const queue = [target];
323
+ while (queue.length > 0 && session.results.length < limit && scanned < MAX_SEARCH_FILES) {
324
+ const current = queue.shift();
310
325
  const items = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
326
+ items.sort((a, b) => a.name.localeCompare(b.name));
311
327
  for (const item of items) {
312
328
  if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) break;
313
- if (item.isDirectory() && SKIP_DIRS.has(item.name)) continue;
329
+ if (item.isDirectory() && skipWalkDirectory(item.name)) continue;
314
330
  const fullPath = path.join(current, item.name);
315
331
  scanned++;
316
332
 
@@ -330,12 +346,9 @@ export class FileManager {
330
346
  }
331
347
  }
332
348
  }
333
-
334
- if (item.isDirectory()) await walk(fullPath);
349
+ if (item.isDirectory()) queue.push(fullPath);
335
350
  }
336
- };
337
-
338
- await walk(target);
351
+ }
339
352
  session.status = "COMPLETED";
340
353
  session.scanned = scanned;
341
354
  } catch (error) {
@@ -66,7 +66,7 @@ async function handlePayload(payload) {
66
66
  case "fs.stat":
67
67
  return files.stat(payload.path);
68
68
  case "fs.list":
69
- return files.list(payload.path, payload.depth);
69
+ return files.list(payload.path, payload.depth, payload.offset, payload.limit, payload.maxBytes);
70
70
  case "fs.read":
71
71
  return files.read(payload.path, payload.offset, payload.length, payload.maxBytes);
72
72
  case "fs.readMany":
@@ -97,13 +97,15 @@ async function handlePayload(payload) {
97
97
  return desktop.mouseClick(payload);
98
98
  case "desktop.keyboard.input":
99
99
  return desktop.keyboardInput(payload);
100
+ case "desktop.step":
101
+ return desktop.step(payload);
100
102
 
101
103
  case "terminal.exec":
102
104
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
103
105
  return terminals.exec(payload.command, payload.cwd, payload.timeoutMs);
104
106
  case "terminal.batch.start":
105
107
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
106
- return terminals.batchStart(payload.jobs, { cwd: payload.cwd, timeoutMs: payload.timeoutMs, concurrency: payload.concurrency });
108
+ return terminals.batchStart(payload.jobs, { cwd: payload.cwd, timeoutMs: payload.timeoutMs, concurrency: payload.concurrency, observability: payload.observability });
107
109
  case "terminal.batch.status":
108
110
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
109
111
  return terminals.batchStatus(payload.batchId);
@@ -115,10 +117,10 @@ async function handlePayload(payload) {
115
117
  return terminals.batchCancel(payload.batchId);
116
118
  case "terminal.start":
117
119
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
118
- return terminals.start(payload.command, payload.cwd);
120
+ return terminals.start(payload.command, payload.cwd, payload.observability);
119
121
  case "terminal.shell.start":
120
122
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
121
- return terminals.startShell(payload.cwd);
123
+ return terminals.startShell(payload.cwd, payload.observability);
122
124
  case "terminal.read":
123
125
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
124
126
  return terminals.read(payload.sessionId, payload.afterSeq, payload.maxChars);
@@ -131,6 +133,9 @@ async function handlePayload(payload) {
131
133
  case "terminal.list":
132
134
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
133
135
  return terminals.list();
136
+ case "terminal.observability":
137
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
138
+ return terminals.observability();
134
139
 
135
140
  default:
136
141
  return { ok: false, error: "unknown_action" };
@@ -140,6 +145,7 @@ async function handlePayload(payload) {
140
145
  function requireReauthorization() {
141
146
  if (reauthorizationRequired) return;
142
147
  reauthorizationRequired = true;
148
+ desktop.close();
143
149
  process.exitCode = 2;
144
150
  console.error("Agent credential was revoked or rejected.");
145
151
  console.error('Recovery: run "chat-relay login --force", then "chat-relay remote".');
@@ -225,4 +231,12 @@ function connect() {
225
231
  });
226
232
  }
227
233
 
234
+ function shutdown() {
235
+ desktop.close();
236
+ }
237
+
238
+ process.once("SIGINT", shutdown);
239
+ process.once("SIGTERM", shutdown);
240
+ process.once("exit", shutdown);
241
+
228
242
  connect();
@@ -119,22 +119,24 @@ export class TerminalManager {
119
119
  });
120
120
  }
121
121
 
122
- start(command, cwd) {
122
+ start(command, cwd, observability) {
123
123
  validateCommand(command);
124
124
  return this.#spawnSession({
125
125
  type: "command",
126
126
  command,
127
127
  cwd: safeCwd(cwd),
128
128
  args: ["-NoLogo", "-NoProfile", "-Command", command],
129
+ observability,
129
130
  });
130
131
  }
131
132
 
132
- startShell(cwd) {
133
+ startShell(cwd, observability) {
133
134
  return this.#spawnSession({
134
135
  type: "shell",
135
136
  command: "powershell",
136
137
  cwd: safeCwd(cwd),
137
138
  args: ["-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
139
+ observability,
138
140
  });
139
141
  }
140
142
 
@@ -143,6 +145,45 @@ export class TerminalManager {
143
145
  return [...this.sessions.values()].map((session) => this.#summary(session));
144
146
  }
145
147
 
148
+ observability() {
149
+ this.#cleanup();
150
+ return {
151
+ ok: true,
152
+ sessions: [...this.sessions.values()].map((session) => ({
153
+ sessionId: session.id,
154
+ type: session.type,
155
+ status: session.status,
156
+ exitCode: session.exitCode,
157
+ startedAt: session.startedAt,
158
+ endedAt: session.endedAt,
159
+ ...(session.observability?.userId ? { userId: session.observability.userId } : {}),
160
+ ...(session.observability?.activityId ? { activityId: session.observability.activityId } : {}),
161
+ ...(session.observability?.toolCallId ? { toolCallId: session.observability.toolCallId } : {}),
162
+ })),
163
+ batches: [...this.batches.values()].map((batch) => ({
164
+ batchId: batch.batchId,
165
+ createdAt: batch.createdAt,
166
+ endedAt: batch.endedAt,
167
+ cancelled: batch.cancelled,
168
+ ...(batch.observability?.userId ? { userId: batch.observability.userId } : {}),
169
+ ...(batch.observability?.activityId ? { activityId: batch.observability.activityId } : {}),
170
+ ...(batch.observability?.toolCallId ? { toolCallId: batch.observability.toolCallId } : {}),
171
+ counts: this.#batchSummary(batch).counts,
172
+ jobs: batch.jobs.map((job) => ({
173
+ jobId: job.jobId,
174
+ status: job.status,
175
+ queuedAt: job.queuedAt,
176
+ startedAt: job.startedAt,
177
+ endedAt: job.endedAt,
178
+ durationMs: job.durationMs,
179
+ exitCode: job.exitCode,
180
+ })),
181
+ })),
182
+ activeExecJobs: this.activeBatchJobs,
183
+ queuedJobs: this.batchQueue.length,
184
+ };
185
+ }
186
+
146
187
  read(sessionId, afterSeq = 0, maxChars = MAX_READ_CHARS) {
147
188
  this.#cleanup();
148
189
  const session = this.#get(sessionId);
@@ -216,6 +257,7 @@ export class TerminalManager {
216
257
  endedAt: null,
217
258
  requestedConcurrency,
218
259
  cancelled: false,
260
+ observability: options.observability ?? null,
219
261
  jobs: jobs.map((input, index) => {
220
262
  const item = typeof input === "string" ? { command: input } : input;
221
263
  validateCommand(item?.command);
@@ -311,7 +353,7 @@ export class TerminalManager {
311
353
  };
312
354
  }
313
355
 
314
- #spawnSession({ type, command, cwd, args }) {
356
+ #spawnSession({ type, command, cwd, args, observability }) {
315
357
  this.#cleanup();
316
358
  const running = [...this.sessions.values()].filter((session) => session.status === "running").length;
317
359
  if (running >= MAX_SESSIONS) throw new Error("too_many_sessions");
@@ -337,6 +379,7 @@ export class TerminalManager {
337
379
  chunks: [],
338
380
  nextSeq: 1,
339
381
  totalChars: 0,
382
+ observability: observability ?? null,
340
383
  };
341
384
 
342
385
  this.sessions.set(session.id, session);
@@ -0,0 +1,380 @@
1
+ param()
2
+
3
+ $ErrorActionPreference = "Stop"
4
+ $ProgressPreference = "SilentlyContinue"
5
+
6
+ Add-Type -AssemblyName System.Windows.Forms
7
+ Add-Type -AssemblyName System.Drawing
8
+
9
+ Add-Type @"
10
+ using System;
11
+ using System.Runtime.InteropServices;
12
+
13
+ public static class ChatRelayNative {
14
+ public const uint INPUT_MOUSE = 0;
15
+ public const uint INPUT_KEYBOARD = 1;
16
+ public const uint KEYEVENTF_KEYUP = 0x0002;
17
+ public const uint KEYEVENTF_UNICODE = 0x0004;
18
+
19
+ [StructLayout(LayoutKind.Sequential)]
20
+ public struct MOUSEINPUT {
21
+ public int dx;
22
+ public int dy;
23
+ public uint mouseData;
24
+ public uint dwFlags;
25
+ public uint time;
26
+ public UIntPtr dwExtraInfo;
27
+ }
28
+
29
+ [StructLayout(LayoutKind.Sequential)]
30
+ public struct KEYBDINPUT {
31
+ public ushort wVk;
32
+ public ushort wScan;
33
+ public uint dwFlags;
34
+ public uint time;
35
+ public UIntPtr dwExtraInfo;
36
+ }
37
+
38
+ [StructLayout(LayoutKind.Explicit)]
39
+ public struct INPUTUNION {
40
+ [FieldOffset(0)] public MOUSEINPUT mi;
41
+ [FieldOffset(0)] public KEYBDINPUT ki;
42
+ }
43
+
44
+ [StructLayout(LayoutKind.Sequential)]
45
+ public struct INPUT {
46
+ public uint type;
47
+ public INPUTUNION U;
48
+ }
49
+
50
+ [DllImport("user32.dll")] public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
51
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
52
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
53
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
54
+
55
+ public static bool MouseButton(uint downFlag, uint upFlag) {
56
+ var inputs = new INPUT[2];
57
+ inputs[0].type = INPUT_MOUSE;
58
+ inputs[0].U.mi.dwFlags = downFlag;
59
+ inputs[1].type = INPUT_MOUSE;
60
+ inputs[1].U.mi.dwFlags = upFlag;
61
+ return SendInput(2, inputs, Marshal.SizeOf(typeof(INPUT))) == 2;
62
+ }
63
+
64
+ public static bool Key(ushort virtualKey, bool down) {
65
+ var input = new INPUT[1];
66
+ input[0].type = INPUT_KEYBOARD;
67
+ input[0].U.ki.wVk = virtualKey;
68
+ input[0].U.ki.dwFlags = down ? 0u : KEYEVENTF_KEYUP;
69
+ return SendInput(1, input, Marshal.SizeOf(typeof(INPUT))) == 1;
70
+ }
71
+
72
+ public static bool UnicodeText(string text) {
73
+ foreach (char c in text) {
74
+ var inputs = new INPUT[2];
75
+ inputs[0].type = INPUT_KEYBOARD;
76
+ inputs[0].U.ki.wScan = c;
77
+ inputs[0].U.ki.dwFlags = KEYEVENTF_UNICODE;
78
+ inputs[1].type = INPUT_KEYBOARD;
79
+ inputs[1].U.ki.wScan = c;
80
+ inputs[1].U.ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP;
81
+ if (SendInput(2, inputs, Marshal.SizeOf(typeof(INPUT))) != 2) return false;
82
+ }
83
+ return true;
84
+ }
85
+ }
86
+ "@
87
+
88
+ [ChatRelayNative]::SetProcessDPIAware() | Out-Null
89
+
90
+ function Result-Error([string]$Code) {
91
+ return [pscustomobject]@{ ok = $false; error = $Code }
92
+ }
93
+
94
+ function Test-InteractiveSession {
95
+ return [System.Windows.Forms.SystemInformation]::UserInteractive -and
96
+ ([ChatRelayNative]::GetForegroundWindow() -ne [IntPtr]::Zero)
97
+ }
98
+
99
+ function Resolve-Screen($Monitor) {
100
+ $screens = @([System.Windows.Forms.Screen]::AllScreens)
101
+ if ($screens.Count -eq 0) { return $null }
102
+
103
+ if ($null -eq $Monitor -or [string]$Monitor -eq "primary") {
104
+ for ($i = 0; $i -lt $screens.Count; $i++) {
105
+ if ($screens[$i].Primary) {
106
+ return [pscustomobject]@{ screen = $screens[$i]; index = $i }
107
+ }
108
+ }
109
+ return $null
110
+ }
111
+
112
+ if ([string]$Monitor -eq "secondary") {
113
+ for ($i = 0; $i -lt $screens.Count; $i++) {
114
+ if (-not $screens[$i].Primary) {
115
+ return [pscustomobject]@{ screen = $screens[$i]; index = $i }
116
+ }
117
+ }
118
+ return $null
119
+ }
120
+
121
+ $parsedIndex = 0
122
+ if (-not [int]::TryParse([string]$Monitor, [ref]$parsedIndex)) { return $null }
123
+ if ($parsedIndex -lt 0 -or $parsedIndex -ge $screens.Count) { return $null }
124
+ return [pscustomobject]@{ screen = $screens[$parsedIndex]; index = $parsedIndex }
125
+ }
126
+
127
+ function Invoke-Screenshot($Payload) {
128
+ if (-not [System.Windows.Forms.SystemInformation]::UserInteractive) {
129
+ return Result-Error "session_unavailable"
130
+ }
131
+
132
+ $resolved = Resolve-Screen $Payload.monitor
133
+ if ($null -eq $resolved) { return Result-Error "monitor_not_found" }
134
+
135
+ $screen = $resolved.screen
136
+ $bounds = $screen.Bounds
137
+ if ($bounds.Width -le 0 -or $bounds.Height -le 0) {
138
+ return Result-Error "session_unavailable"
139
+ }
140
+
141
+ $source = $null
142
+ try {
143
+ $source = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
144
+ $graphics = [System.Drawing.Graphics]::FromImage($source)
145
+ try {
146
+ $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size)
147
+ } finally {
148
+ $graphics.Dispose()
149
+ }
150
+
151
+ $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
152
+ Where-Object { $_.MimeType -eq "image/jpeg" } |
153
+ Select-Object -First 1
154
+ if ($null -eq $codec) { return Result-Error "capture_failed" }
155
+
156
+ $maxBytes = 32768
157
+ $targetWidth = [Math]::Min($bounds.Width, 960)
158
+ $targetHeight = [Math]::Max(1, [int][Math]::Round($bounds.Height * ($targetWidth / [double]$bounds.Width)))
159
+ $qualities = @(58L, 42L, 30L, 22L)
160
+ $bytes = $null
161
+ $finalWidth = $targetWidth
162
+ $finalHeight = $targetHeight
163
+
164
+ for ($sizeAttempt = 0; $sizeAttempt -lt 3 -and $null -eq $bytes; $sizeAttempt++) {
165
+ $resized = New-Object System.Drawing.Bitmap $targetWidth, $targetHeight
166
+ $draw = [System.Drawing.Graphics]::FromImage($resized)
167
+ try {
168
+ $draw.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighSpeed
169
+ $draw.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::Bilinear
170
+ $draw.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighSpeed
171
+ $draw.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighSpeed
172
+ $draw.DrawImage($source, 0, 0, $targetWidth, $targetHeight)
173
+ } finally {
174
+ $draw.Dispose()
175
+ }
176
+
177
+ try {
178
+ foreach ($quality in $qualities) {
179
+ $stream = New-Object System.IO.MemoryStream
180
+ $parameters = New-Object System.Drawing.Imaging.EncoderParameters 1
181
+ $parameters.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter(
182
+ [System.Drawing.Imaging.Encoder]::Quality,
183
+ $quality
184
+ )
185
+ try {
186
+ $resized.Save($stream, $codec, $parameters)
187
+ if ($stream.Length -le $maxBytes) {
188
+ $bytes = $stream.ToArray()
189
+ $finalWidth = $targetWidth
190
+ $finalHeight = $targetHeight
191
+ break
192
+ }
193
+ } finally {
194
+ $parameters.Dispose()
195
+ $stream.Dispose()
196
+ }
197
+ }
198
+ } finally {
199
+ $resized.Dispose()
200
+ }
201
+
202
+ if ($null -eq $bytes) {
203
+ $targetWidth = [Math]::Max(320, [int][Math]::Floor($targetWidth * 0.78))
204
+ $targetHeight = [Math]::Max(180, [int][Math]::Round($bounds.Height * ($targetWidth / [double]$bounds.Width)))
205
+ }
206
+ }
207
+
208
+ if ($null -eq $bytes) { return Result-Error "screenshot_too_large" }
209
+
210
+ return [pscustomobject]@{
211
+ ok = $true
212
+ mimeType = "image/jpeg"
213
+ data = [Convert]::ToBase64String($bytes)
214
+ width = $finalWidth
215
+ height = $finalHeight
216
+ desktopOriginX = $bounds.Left
217
+ desktopOriginY = $bounds.Top
218
+ desktopWidth = $bounds.Width
219
+ desktopHeight = $bounds.Height
220
+ scaleX = $bounds.Width / [double]$finalWidth
221
+ scaleY = $bounds.Height / [double]$finalHeight
222
+ monitorIndex = $resolved.index
223
+ isPrimary = [bool]$screen.Primary
224
+ deviceName = [string]$screen.DeviceName
225
+ byteLength = $bytes.Length
226
+ }
227
+ } catch {
228
+ return Result-Error "capture_failed"
229
+ } finally {
230
+ if ($null -ne $source) { $source.Dispose() }
231
+ }
232
+ }
233
+
234
+ function Invoke-MouseClick($Payload) {
235
+ if (-not (Test-InteractiveSession)) { return Result-Error "session_unavailable" }
236
+
237
+ $x = [int]$Payload.x
238
+ $y = [int]$Payload.y
239
+ $containsPoint = $false
240
+ foreach ($screen in @([System.Windows.Forms.Screen]::AllScreens)) {
241
+ if ($screen.Bounds.Contains($x, $y)) {
242
+ $containsPoint = $true
243
+ break
244
+ }
245
+ }
246
+ if (-not $containsPoint) { return Result-Error "invalid_coordinates" }
247
+
248
+ switch ([string]$Payload.button) {
249
+ "left" { $down = 0x0002; $up = 0x0004 }
250
+ "right" { $down = 0x0008; $up = 0x0010 }
251
+ "middle" { $down = 0x0020; $up = 0x0040 }
252
+ default { return Result-Error "invalid_button" }
253
+ }
254
+
255
+ if (-not [ChatRelayNative]::SetCursorPos($x, $y)) { return Result-Error "input_failed" }
256
+
257
+ $clicks = [int]$Payload.clicks
258
+ for ($i = 0; $i -lt $clicks; $i++) {
259
+ if (-not [ChatRelayNative]::MouseButton($down, $up)) { return Result-Error "input_failed" }
260
+ if ($i + 1 -lt $clicks) { Start-Sleep -Milliseconds 100 }
261
+ }
262
+
263
+ return [pscustomobject]@{ ok = $true; action = "mouse_click" }
264
+ }
265
+
266
+ function Invoke-KeyboardInput($Payload) {
267
+ if (-not (Test-InteractiveSession)) { return Result-Error "session_unavailable" }
268
+
269
+ if ($null -ne $Payload.text) {
270
+ if (-not [ChatRelayNative]::UnicodeText([string]$Payload.text)) {
271
+ return Result-Error "input_failed"
272
+ }
273
+ } else {
274
+ $mods = New-Object System.Collections.Generic.List[uint16]
275
+ if ($Payload.ctrl) { $mods.Add(0x11) }
276
+ if ($Payload.alt) { $mods.Add(0x12) }
277
+ if ($Payload.shift) { $mods.Add(0x10) }
278
+ if ($Payload.win) { $mods.Add(0x5B) }
279
+
280
+ foreach ($vk in $mods) {
281
+ if (-not [ChatRelayNative]::Key($vk, $true)) { return Result-Error "input_failed" }
282
+ }
283
+
284
+ try {
285
+ $keyCode = [uint16]$Payload.keyCode
286
+ if (-not [ChatRelayNative]::Key($keyCode, $true)) { return Result-Error "input_failed" }
287
+ if (-not [ChatRelayNative]::Key($keyCode, $false)) { return Result-Error "input_failed" }
288
+ } finally {
289
+ for ($i = $mods.Count - 1; $i -ge 0; $i--) {
290
+ [ChatRelayNative]::Key($mods[$i], $false) | Out-Null
291
+ }
292
+ }
293
+ }
294
+
295
+ return [pscustomobject]@{ ok = $true; action = "keyboard_input" }
296
+ }
297
+
298
+ function Invoke-Step($Payload) {
299
+ $completed = 0
300
+ foreach ($action in @($Payload.actions)) {
301
+ $result = $null
302
+ switch ([string]$action.type) {
303
+ "click" { $result = Invoke-MouseClick $action }
304
+ "text" { $result = Invoke-KeyboardInput $action }
305
+ "key" { $result = Invoke-KeyboardInput $action }
306
+ "wait" {
307
+ Start-Sleep -Milliseconds ([int]$action.ms)
308
+ $result = [pscustomobject]@{ ok = $true; action = "wait" }
309
+ }
310
+ default { $result = Result-Error "invalid_step_action" }
311
+ }
312
+
313
+ if (-not $result.ok) {
314
+ return [pscustomobject]@{
315
+ ok = $false
316
+ error = $result.error
317
+ actionsCompleted = $completed
318
+ }
319
+ }
320
+ $completed++
321
+ }
322
+
323
+ if ([int]$Payload.settleMs -gt 0) {
324
+ Start-Sleep -Milliseconds ([int]$Payload.settleMs)
325
+ }
326
+
327
+ if ($Payload.captureAfter -eq $false) {
328
+ return [pscustomobject]@{
329
+ ok = $true
330
+ action = "step"
331
+ actionsCompleted = $completed
332
+ }
333
+ }
334
+
335
+ $shotPayload = [pscustomobject]@{ monitor = $Payload.monitor }
336
+ $shot = Invoke-Screenshot $shotPayload
337
+ if (-not $shot.ok) {
338
+ return [pscustomobject]@{
339
+ ok = $false
340
+ error = $shot.error
341
+ actionsCompleted = $completed
342
+ }
343
+ }
344
+
345
+ $shot | Add-Member -NotePropertyName action -NotePropertyValue "step" -Force
346
+ $shot | Add-Member -NotePropertyName actionsCompleted -NotePropertyValue $completed -Force
347
+ return $shot
348
+ }
349
+
350
+ function Invoke-Operation([string]$Operation, $Payload) {
351
+ switch ($Operation) {
352
+ "screenshot" { return Invoke-Screenshot $Payload }
353
+ "mouse_click" { return Invoke-MouseClick $Payload }
354
+ "keyboard_input" { return Invoke-KeyboardInput $Payload }
355
+ "step" { return Invoke-Step $Payload }
356
+ default { return Result-Error "unknown_desktop_operation" }
357
+ }
358
+ }
359
+
360
+ while (($line = [Console]::In.ReadLine()) -ne $null) {
361
+ if ([string]::IsNullOrWhiteSpace($line)) { continue }
362
+
363
+ $id = $null
364
+ $result = $null
365
+ try {
366
+ $request = $line | ConvertFrom-Json
367
+ $id = [string]$request.id
368
+ $result = Invoke-Operation ([string]$request.operation) $request.args
369
+ } catch {
370
+ $result = Result-Error "desktop_worker_error"
371
+ }
372
+
373
+ $response = [pscustomobject]@{
374
+ id = $id
375
+ result = $result
376
+ }
377
+
378
+ [Console]::Out.WriteLine(($response | ConvertTo-Json -Compress -Depth 10))
379
+ [Console]::Out.Flush()
380
+ }
package/package.json CHANGED
@@ -1,75 +1,75 @@
1
1
  {
2
- "name": "@anusornneal/chat-relay",
3
- "version": "0.7.1",
4
- "description": "Zero-checkout remote MCP agent for ChatGPT with terminal, filesystem, process, multi-user, and multi-agent support.",
5
- "type": "module",
6
- "bin": {
7
- "chat-relay": "bin/chat-relay.js"
8
- },
9
- "files": [
10
- "bin/",
11
- "cli/",
12
- "agent/",
13
- "README.md"
14
- ],
15
- "engines": {
16
- "node": "\u003e=20"
17
- },
18
- "publishConfig": {
19
- "access": "public"
20
- },
21
- "scripts": {
22
- "start": "node bin/chat-relay.js remote",
23
- "remote": "node bin/chat-relay.js remote",
24
- "login": "node bin/chat-relay.js login",
25
- "logout": "node bin/chat-relay.js logout",
26
- "status": "node bin/chat-relay.js status",
27
- "dev": "wrangler dev",
28
- "dev:test": "wrangler dev --local --port 8790 --var AGENT_TOKEN:test-agent --var CALLER_TOKEN=test-caller --var ADMIN_TOKEN=test-admin",
29
- "deploy": "wrangler deploy",
30
- "agent": "node --env-file-if-exists=.dev.vars agent/local-agent.mjs",
31
- "admin": "node scripts/admin.mjs",
32
- "test:terminal": "node test/mcp-terminal-smoke.mjs",
33
- "test:full": "node test/multiuser-smoke.mjs",
34
- "test:device-auth": "node test/device-auth-smoke.mjs",
35
- "test:zero-checkout": "node test/zero-checkout-smoke.mjs",
36
- "verify:publish": "npm run verify:review \u0026\u0026 node scripts/verify-publish.mjs \u0026\u0026 npx wrangler deploy --dry-run \u0026\u0026 npm audit --omit=dev",
37
- "prepublishOnly": "npm run verify:publish",
38
- "test:oauth": "node test/oauth-smoke.mjs",
39
- "test:usage": "node test/usage-smoke.mjs",
40
- "test:admin-api": "node test/admin-api-smoke.mjs",
41
- "test:admin-auth": "node test/admin-auth-smoke.mjs",
42
- "test:dashboard": "node test/dashboard-smoke.mjs",
43
- "test:quota": "node test/quota-smoke.mjs",
44
- "test:ops": "node test/ops-smoke.mjs",
45
- "test:desktop": "node test/mcp-desktop-smoke.mjs",
46
- "test:device-management": "node test/device-management-smoke.mjs",
47
- "test:submission": "node test/submission-smoke.mjs",
48
- "verify:review": "node scripts/verify-review-artifacts.mjs"
49
- },
50
- "dependencies": {
51
- "ws": "^8.22.0"
52
- },
53
- "devDependencies": {
54
- "@modelcontextprotocol/server": "^2.0.0",
55
- "agents": "^0.24.0",
56
- "wrangler": "^4.143.0",
57
- "zod": "^4.6.5"
58
- },
59
- "repository": {
60
- "type": "git",
61
- "url": "git+https://github.com/anusornNeal/chat-relay.git"
62
- },
63
- "bugs": {
64
- "url": "https://github.com/anusornNeal/chat-relay/issues"
65
- },
66
- "homepage": "https://github.com/anusornNeal/chat-relay#readme",
67
- "keywords": [
68
- "chatgpt",
69
- "mcp",
70
- "remote-agent",
71
- "terminal",
72
- "cloudflare",
73
- "cli"
74
- ]
2
+ "name": "@anusornneal/chat-relay",
3
+ "version": "0.7.2",
4
+ "description": "Zero-checkout remote MCP agent for ChatGPT with terminal, filesystem, process, multi-user, and multi-agent support.",
5
+ "type": "module",
6
+ "bin": {
7
+ "chat-relay": "bin/chat-relay.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "cli/",
12
+ "agent/",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "start": "node bin/chat-relay.js remote",
23
+ "remote": "node bin/chat-relay.js remote",
24
+ "login": "node bin/chat-relay.js login",
25
+ "logout": "node bin/chat-relay.js logout",
26
+ "status": "node bin/chat-relay.js status",
27
+ "dev": "wrangler dev",
28
+ "dev:test": "wrangler dev --local --port 8790 --var AGENT_TOKEN:test-agent --var CALLER_TOKEN=test-caller --var ADMIN_TOKEN=test-admin",
29
+ "deploy": "wrangler deploy",
30
+ "agent": "node --env-file-if-exists=.dev.vars agent/local-agent.mjs",
31
+ "admin": "node scripts/admin.mjs",
32
+ "test:terminal": "node test/mcp-terminal-smoke.mjs",
33
+ "test:full": "node test/multiuser-smoke.mjs",
34
+ "test:device-auth": "node test/device-auth-smoke.mjs",
35
+ "test:zero-checkout": "node test/zero-checkout-smoke.mjs",
36
+ "verify:publish": "npm run verify:review && node scripts/verify-publish.mjs && npx wrangler deploy --dry-run && npm audit --omit=dev",
37
+ "prepublishOnly": "npm run verify:publish",
38
+ "test:oauth": "node test/oauth-smoke.mjs",
39
+ "test:usage": "node test/usage-smoke.mjs",
40
+ "test:admin-api": "node test/admin-api-smoke.mjs",
41
+ "test:admin-auth": "node test/admin-auth-smoke.mjs",
42
+ "test:dashboard": "node test/dashboard-smoke.mjs",
43
+ "test:quota": "node test/quota-smoke.mjs",
44
+ "test:ops": "node test/ops-smoke.mjs",
45
+ "test:desktop": "node test/mcp-desktop-smoke.mjs",
46
+ "test:device-management": "node test/device-management-smoke.mjs",
47
+ "test:submission": "node test/submission-smoke.mjs",
48
+ "verify:review": "node scripts/verify-review-artifacts.mjs"
49
+ },
50
+ "dependencies": {
51
+ "ws": "^8.22.0"
52
+ },
53
+ "devDependencies": {
54
+ "@modelcontextprotocol/server": "^2.0.0",
55
+ "agents": "^0.24.0",
56
+ "wrangler": "^4.143.0",
57
+ "zod": "^4.6.5"
58
+ },
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/anusornNeal/chat-relay.git"
62
+ },
63
+ "bugs": {
64
+ "url": "https://github.com/anusornNeal/chat-relay/issues"
65
+ },
66
+ "homepage": "https://github.com/anusornNeal/chat-relay#readme",
67
+ "keywords": [
68
+ "chatgpt",
69
+ "mcp",
70
+ "remote-agent",
71
+ "terminal",
72
+ "cloudflare",
73
+ "cli"
74
+ ]
75
75
  }