@anusornneal/chat-relay 0.7.0 → 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/README.md +9 -0
- package/agent/desktop-manager.mjs +319 -175
- package/agent/file-manager.mjs +36 -23
- package/agent/local-agent.mjs +35 -5
- package/agent/terminal-manager.mjs +550 -229
- package/agent/windows-desktop-worker.ps1 +380 -0
- package/agent/windows-desktop.ps1 +26 -1
- package/package.json +73 -73
package/README.md
CHANGED
|
@@ -131,6 +131,10 @@ Processes:
|
|
|
131
131
|
|
|
132
132
|
Terminal:
|
|
133
133
|
- `terminal_exec`
|
|
134
|
+
- `terminal_batch_start`
|
|
135
|
+
- `terminal_batch_status`
|
|
136
|
+
- `terminal_batch_read`
|
|
137
|
+
- `terminal_batch_cancel`
|
|
134
138
|
- `terminal_start`
|
|
135
139
|
- `terminal_start_shell`
|
|
136
140
|
- `terminal_read`
|
|
@@ -225,6 +229,11 @@ The OpenAI domain verification token is served at `/.well-known/openai-apps-chal
|
|
|
225
229
|
- `read_file` defaults to a 32 KiB content budget (max 48 KiB) and exposes deterministic `nextOffset` continuation.
|
|
226
230
|
- `read_multiple_files` defaults to a 48 KiB aggregate budget (max 48 KiB). If not all requested files fit, use `nextIndex`; if an individual file is truncated, continue it with that entry's `nextOffset`.
|
|
227
231
|
- One-shot terminal command: max 20 seconds.
|
|
232
|
+
- `terminal_batch_start` accepts 2-20 jobs in one MCP call to avoid N-call E2E dispatch overhead. Each agent owns an independent FIFO queue and bounded execution pool.
|
|
233
|
+
- Batch concurrency defaults to 4 and is capped at 8 per agent. Override with `TERMINAL_BATCH_CONCURRENCY`; queued jobs default to 64 and are capped at 256 via `TERMINAL_BATCH_MAX_QUEUED`.
|
|
234
|
+
- Queue overflow fails fast with `queue_full` instead of spawning unbounded processes. Use `terminal_batch_status`, `terminal_batch_read`, and `terminal_batch_cancel` for lifecycle control.
|
|
235
|
+
- Batch output retained in memory is capped at 8 KiB per job; completed batches are capped at 64 per agent and also expire after 30 minutes.
|
|
236
|
+
- Scaling is horizontal by agent: each connected machine has its own queue/concurrency budget, so additional agents add execution capacity without sharing one local hot queue.
|
|
228
237
|
- Persistent terminal sessions: up to 8 running sessions per local agent.
|
|
229
238
|
- Terminal output buffer: bounded in memory; completed sessions retained for 30 minutes.
|
|
230
239
|
- Filesystem reads/writes are bounded and restricted to configured `ALLOWED_ROOTS`.
|
|
@@ -1,175 +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
|
-
|
|
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
|
-
|
|
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) {
|