@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.
- package/agent/desktop-manager.mjs +319 -183
- package/agent/file-manager.mjs +36 -23
- package/agent/local-agent.mjs +18 -4
- package/agent/terminal-manager.mjs +46 -3
- package/agent/windows-desktop-worker.ps1 +380 -0
- package/package.json +73 -73
|
@@ -1,183 +1,319 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { fileURLToPath } from "node:url";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
["
|
|
14
|
-
["
|
|
15
|
-
["
|
|
16
|
-
["
|
|
17
|
-
["
|
|
18
|
-
["
|
|
19
|
-
["
|
|
20
|
-
["
|
|
21
|
-
["
|
|
22
|
-
["
|
|
23
|
-
["
|
|
24
|
-
["
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
...Array.from({ length:
|
|
28
|
-
])
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
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 };
|
package/agent/file-manager.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
309
|
-
|
|
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() &&
|
|
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) {
|
package/agent/local-agent.mjs
CHANGED
|
@@ -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":
|
|
3
|
-
"version":
|
|
4
|
-
"description":
|
|
5
|
-
"type":
|
|
6
|
-
"bin":
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"files":
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"engines":
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"publishConfig":
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"scripts":
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"dependencies":
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"devDependencies":
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"repository":
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"bugs":
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"homepage":
|
|
67
|
-
"keywords":
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
}
|