@anusornneal/chat-relay 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/agent/desktop-manager.mjs +10 -2
- package/agent/local-agent.mjs +18 -2
- package/agent/terminal-manager.mjs +507 -229
- package/agent/windows-desktop.ps1 +26 -1
- package/package.json +1 -1
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`.
|
|
@@ -94,11 +94,16 @@ export class DesktopManager {
|
|
|
94
94
|
return null;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
async screenshot() {
|
|
97
|
+
async screenshot(input = {}) {
|
|
98
98
|
const gate = this.gate();
|
|
99
99
|
if (gate) return gate;
|
|
100
100
|
|
|
101
|
-
const
|
|
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 });
|
|
102
107
|
if (!result?.ok) return result || { ok: false, error: "capture_failed" };
|
|
103
108
|
if (result.mimeType !== "image/jpeg" || typeof result.data !== "string") {
|
|
104
109
|
return { ok: false, error: "capture_failed" };
|
|
@@ -121,6 +126,9 @@ export class DesktopManager {
|
|
|
121
126
|
desktopHeight: Number(result.desktopHeight),
|
|
122
127
|
scaleX: Number(result.scaleX),
|
|
123
128
|
scaleY: Number(result.scaleY),
|
|
129
|
+
monitorIndex: Number(result.monitorIndex),
|
|
130
|
+
isPrimary: result.isPrimary === true,
|
|
131
|
+
deviceName: typeof result.deviceName === "string" ? result.deviceName : undefined,
|
|
124
132
|
byteLength: binaryBytes,
|
|
125
133
|
};
|
|
126
134
|
}
|
package/agent/local-agent.mjs
CHANGED
|
@@ -12,7 +12,10 @@ const reconnectMs = Number(process.env.RECONNECT_MS ?? 2000);
|
|
|
12
12
|
const terminalEnabled = process.env.TERMINAL_ENABLED === "1";
|
|
13
13
|
const desktopEnabled = process.env.DESKTOP_ENABLED === "1";
|
|
14
14
|
const MAX_RESPONSE_BYTES = 60 * 1024;
|
|
15
|
-
const terminals = new TerminalManager(
|
|
15
|
+
const terminals = new TerminalManager({
|
|
16
|
+
batchConcurrency: process.env.TERMINAL_BATCH_CONCURRENCY,
|
|
17
|
+
maxQueuedJobs: process.env.TERMINAL_BATCH_MAX_QUEUED,
|
|
18
|
+
});
|
|
16
19
|
const files = new FileManager(process.env.ALLOWED_ROOTS);
|
|
17
20
|
const processes = new ProcessManager();
|
|
18
21
|
const desktop = new DesktopManager({ enabled: desktopEnabled });
|
|
@@ -52,6 +55,7 @@ async function handlePayload(payload) {
|
|
|
52
55
|
agentId,
|
|
53
56
|
agentName,
|
|
54
57
|
terminalEnabled,
|
|
58
|
+
terminalBatch: terminals.getBatchConfig(),
|
|
55
59
|
desktop: desktop.getConfig(),
|
|
56
60
|
allowedRoots: files.getRoots(),
|
|
57
61
|
reconnectMs,
|
|
@@ -88,7 +92,7 @@ async function handlePayload(payload) {
|
|
|
88
92
|
return processes.kill(payload.pid);
|
|
89
93
|
|
|
90
94
|
case "desktop.screenshot":
|
|
91
|
-
return desktop.screenshot();
|
|
95
|
+
return desktop.screenshot(payload);
|
|
92
96
|
case "desktop.mouse.click":
|
|
93
97
|
return desktop.mouseClick(payload);
|
|
94
98
|
case "desktop.keyboard.input":
|
|
@@ -97,6 +101,18 @@ async function handlePayload(payload) {
|
|
|
97
101
|
case "terminal.exec":
|
|
98
102
|
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
99
103
|
return terminals.exec(payload.command, payload.cwd, payload.timeoutMs);
|
|
104
|
+
case "terminal.batch.start":
|
|
105
|
+
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
106
|
+
return terminals.batchStart(payload.jobs, { cwd: payload.cwd, timeoutMs: payload.timeoutMs, concurrency: payload.concurrency });
|
|
107
|
+
case "terminal.batch.status":
|
|
108
|
+
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
109
|
+
return terminals.batchStatus(payload.batchId);
|
|
110
|
+
case "terminal.batch.read":
|
|
111
|
+
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
112
|
+
return terminals.batchRead(payload.batchId, payload.maxChars);
|
|
113
|
+
case "terminal.batch.cancel":
|
|
114
|
+
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
115
|
+
return terminals.batchCancel(payload.batchId);
|
|
100
116
|
case "terminal.start":
|
|
101
117
|
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
102
118
|
return terminals.start(payload.command, payload.cwd);
|
|
@@ -1,229 +1,507 @@
|
|
|
1
|
-
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
|
|
5
|
-
const MAX_EXEC_BUFFER = 48 * 1024;
|
|
6
|
-
const MAX_SESSION_BUFFER = 256 * 1024;
|
|
7
|
-
const MAX_READ_CHARS = 24 * 1024;
|
|
8
|
-
const MAX_INPUT_CHARS = 8 * 1024;
|
|
9
|
-
const MAX_SESSIONS = 8;
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
this.#cleanup();
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
nextSeq: 1,
|
|
166
|
-
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
const MAX_EXEC_BUFFER = 48 * 1024;
|
|
6
|
+
const MAX_SESSION_BUFFER = 256 * 1024;
|
|
7
|
+
const MAX_READ_CHARS = 24 * 1024;
|
|
8
|
+
const MAX_INPUT_CHARS = 8 * 1024;
|
|
9
|
+
const MAX_SESSIONS = 8;
|
|
10
|
+
const MAX_BATCH_SIZE = 20;
|
|
11
|
+
const MAX_BATCH_CONCURRENCY = 8;
|
|
12
|
+
const DEFAULT_BATCH_CONCURRENCY = 4;
|
|
13
|
+
const DEFAULT_MAX_QUEUED_JOBS = 64;
|
|
14
|
+
const MAX_QUEUED_JOBS = 256;
|
|
15
|
+
const MAX_BATCH_READ_CHARS = 24 * 1024;
|
|
16
|
+
const MAX_JOB_READ_CHARS = 8 * 1024;
|
|
17
|
+
const MAX_RETAINED_BATCHES = 64;
|
|
18
|
+
const ENDED_TTL_MS = 30 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const BLOCKED_COMMANDS = [
|
|
21
|
+
/\b(mkfs|format|mount|umount|fdisk|dd|parted|diskpart|sudo|su|passwd)\b/i,
|
|
22
|
+
/\b(adduser|useradd|usermod|groupadd|chsh|visudo|shutdown|reboot|halt)\b/i,
|
|
23
|
+
/\b(poweroff|init|iptables|firewall|netsh|sfc|bcdedit|runas|cipher|takeown)\b/i,
|
|
24
|
+
/\b(reg|sc)\.exe\b/i,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function validateCommand(command) {
|
|
28
|
+
if (typeof command !== "string" || command.length === 0 || command.length > 4000) {
|
|
29
|
+
throw new Error("invalid_command");
|
|
30
|
+
}
|
|
31
|
+
if (BLOCKED_COMMANDS.some((pattern) => pattern.test(command))) {
|
|
32
|
+
throw new Error("command_blocked");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeTimeout(timeoutMs, fallback = 15000, max = 20000) {
|
|
37
|
+
const value = Number(timeoutMs);
|
|
38
|
+
if (!Number.isFinite(value)) return fallback;
|
|
39
|
+
return Math.min(Math.max(Math.trunc(value), 1000), max);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeInteger(value, fallback, min, max) {
|
|
43
|
+
const number = Number(value);
|
|
44
|
+
if (!Number.isFinite(number)) return fallback;
|
|
45
|
+
return Math.min(Math.max(Math.trunc(number), min), max);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function safeCwd(cwd) {
|
|
49
|
+
if (cwd === undefined || cwd === null || cwd === "") return process.cwd();
|
|
50
|
+
if (typeof cwd !== "string" || cwd.length > 1024) throw new Error("invalid_cwd");
|
|
51
|
+
return cwd;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clipText(value, limit) {
|
|
55
|
+
const text = typeof value === "string" ? value : "";
|
|
56
|
+
if (text.length <= limit) return { text, truncated: false };
|
|
57
|
+
return { text: text.slice(0, limit), truncated: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clipJobOutput(stdout, stderr, limit = MAX_JOB_READ_CHARS) {
|
|
61
|
+
const rawStdout = typeof stdout === "string" ? stdout : "";
|
|
62
|
+
const rawStderr = typeof stderr === "string" ? stderr : "";
|
|
63
|
+
const stdoutBudget = rawStderr ? Math.floor(limit / 2) : limit;
|
|
64
|
+
const clippedStdout = clipText(rawStdout, stdoutBudget);
|
|
65
|
+
const stderrBudget = Math.max(0, limit - clippedStdout.text.length);
|
|
66
|
+
const clippedStderr = clipText(rawStderr, stderrBudget);
|
|
67
|
+
return {
|
|
68
|
+
stdout: clippedStdout.text,
|
|
69
|
+
stderr: clippedStderr.text,
|
|
70
|
+
truncated: clippedStdout.truncated || clippedStderr.truncated,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class TerminalManager {
|
|
75
|
+
constructor(options = {}) {
|
|
76
|
+
this.sessions = new Map();
|
|
77
|
+
this.batches = new Map();
|
|
78
|
+
this.batchQueue = [];
|
|
79
|
+
this.activeBatchJobs = 0;
|
|
80
|
+
this.batchConcurrency = normalizeInteger(
|
|
81
|
+
options.batchConcurrency,
|
|
82
|
+
DEFAULT_BATCH_CONCURRENCY,
|
|
83
|
+
1,
|
|
84
|
+
MAX_BATCH_CONCURRENCY,
|
|
85
|
+
);
|
|
86
|
+
this.maxQueuedJobs = normalizeInteger(
|
|
87
|
+
options.maxQueuedJobs,
|
|
88
|
+
DEFAULT_MAX_QUEUED_JOBS,
|
|
89
|
+
1,
|
|
90
|
+
MAX_QUEUED_JOBS,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async exec(command, cwd, timeoutMs) {
|
|
95
|
+
validateCommand(command);
|
|
96
|
+
const resolvedCwd = safeCwd(cwd);
|
|
97
|
+
const timeout = normalizeTimeout(timeoutMs);
|
|
98
|
+
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
execFile(
|
|
101
|
+
"powershell.exe",
|
|
102
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command],
|
|
103
|
+
{
|
|
104
|
+
cwd: resolvedCwd,
|
|
105
|
+
timeout,
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
maxBuffer: MAX_EXEC_BUFFER,
|
|
108
|
+
},
|
|
109
|
+
(error, stdout, stderr) => {
|
|
110
|
+
resolve({
|
|
111
|
+
ok: !error,
|
|
112
|
+
exitCode: typeof error?.code === "number" ? error.code : (error ? 1 : 0),
|
|
113
|
+
stdout: stdout ?? "",
|
|
114
|
+
stderr: stderr ?? "",
|
|
115
|
+
error: error ? error.message : null,
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
start(command, cwd) {
|
|
123
|
+
validateCommand(command);
|
|
124
|
+
return this.#spawnSession({
|
|
125
|
+
type: "command",
|
|
126
|
+
command,
|
|
127
|
+
cwd: safeCwd(cwd),
|
|
128
|
+
args: ["-NoLogo", "-NoProfile", "-Command", command],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
startShell(cwd) {
|
|
133
|
+
return this.#spawnSession({
|
|
134
|
+
type: "shell",
|
|
135
|
+
command: "powershell",
|
|
136
|
+
cwd: safeCwd(cwd),
|
|
137
|
+
args: ["-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
list() {
|
|
142
|
+
this.#cleanup();
|
|
143
|
+
return [...this.sessions.values()].map((session) => this.#summary(session));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
read(sessionId, afterSeq = 0, maxChars = MAX_READ_CHARS) {
|
|
147
|
+
this.#cleanup();
|
|
148
|
+
const session = this.#get(sessionId);
|
|
149
|
+
const limit = Math.min(Math.max(Number(maxChars) || MAX_READ_CHARS, 1024), MAX_READ_CHARS);
|
|
150
|
+
const oldestSeq = session.chunks[0]?.seq ?? session.nextSeq;
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let chars = 0;
|
|
153
|
+
|
|
154
|
+
for (const chunk of session.chunks) {
|
|
155
|
+
if (chunk.seq <= afterSeq) continue;
|
|
156
|
+
if (chunks.length > 0 && chars + chunk.text.length > limit) break;
|
|
157
|
+
chunks.push(chunk);
|
|
158
|
+
chars += chunk.text.length;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...this.#summary(session),
|
|
163
|
+
chunks,
|
|
164
|
+
oldestSeq,
|
|
165
|
+
nextSeq: chunks.at(-1)?.seq ?? afterSeq,
|
|
166
|
+
truncated: afterSeq < oldestSeq - 1,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
write(sessionId, input, appendNewline = true) {
|
|
171
|
+
const session = this.#get(sessionId);
|
|
172
|
+
if (session.status !== "running") throw new Error("session_not_running");
|
|
173
|
+
if (typeof input !== "string" || input.length === 0 || input.length > MAX_INPUT_CHARS) {
|
|
174
|
+
throw new Error("invalid_input");
|
|
175
|
+
}
|
|
176
|
+
session.process.stdin.write(input + (appendNewline ? os.EOL : ""));
|
|
177
|
+
return this.#summary(session);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async kill(sessionId) {
|
|
181
|
+
const session = this.#get(sessionId);
|
|
182
|
+
if (session.status !== "running") return this.#summary(session);
|
|
183
|
+
|
|
184
|
+
await new Promise((resolve) => {
|
|
185
|
+
execFile("taskkill.exe", ["/PID", String(session.pid), "/T", "/F"], () => resolve());
|
|
186
|
+
});
|
|
187
|
+
return this.#summary(session);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
batchStart(jobs, options = {}) {
|
|
191
|
+
this.#cleanup();
|
|
192
|
+
if (!Array.isArray(jobs) || jobs.length < 2 || jobs.length > MAX_BATCH_SIZE) {
|
|
193
|
+
throw new Error("invalid_batch_size");
|
|
194
|
+
}
|
|
195
|
+
const defaultCwd = safeCwd(options.cwd);
|
|
196
|
+
const defaultTimeoutMs = normalizeTimeout(options.timeoutMs);
|
|
197
|
+
const requestedConcurrency = normalizeInteger(
|
|
198
|
+
options.concurrency,
|
|
199
|
+
this.batchConcurrency,
|
|
200
|
+
1,
|
|
201
|
+
this.batchConcurrency,
|
|
202
|
+
);
|
|
203
|
+
const availableSlots = Math.max(0, this.batchConcurrency - this.activeBatchJobs);
|
|
204
|
+
const immediatelyRunnable = this.batchQueue.length === 0
|
|
205
|
+
? Math.min(jobs.length, availableSlots, requestedConcurrency)
|
|
206
|
+
: 0;
|
|
207
|
+
const projectedQueuedJobs = this.batchQueue.length + jobs.length - immediatelyRunnable;
|
|
208
|
+
if (projectedQueuedJobs > this.maxQueuedJobs) {
|
|
209
|
+
throw new Error("queue_full");
|
|
210
|
+
}
|
|
211
|
+
const batchId = randomUUID();
|
|
212
|
+
const createdAt = new Date().toISOString();
|
|
213
|
+
const batch = {
|
|
214
|
+
batchId,
|
|
215
|
+
createdAt,
|
|
216
|
+
endedAt: null,
|
|
217
|
+
requestedConcurrency,
|
|
218
|
+
cancelled: false,
|
|
219
|
+
jobs: jobs.map((input, index) => {
|
|
220
|
+
const item = typeof input === "string" ? { command: input } : input;
|
|
221
|
+
validateCommand(item?.command);
|
|
222
|
+
return {
|
|
223
|
+
jobId: randomUUID(),
|
|
224
|
+
index,
|
|
225
|
+
command: item.command,
|
|
226
|
+
cwd: safeCwd(item.cwd ?? defaultCwd),
|
|
227
|
+
timeoutMs: normalizeTimeout(item.timeoutMs, defaultTimeoutMs),
|
|
228
|
+
status: "queued",
|
|
229
|
+
queuedAt: createdAt,
|
|
230
|
+
startedAt: null,
|
|
231
|
+
endedAt: null,
|
|
232
|
+
queueWaitMs: null,
|
|
233
|
+
durationMs: null,
|
|
234
|
+
exitCode: null,
|
|
235
|
+
stdout: "",
|
|
236
|
+
stderr: "",
|
|
237
|
+
outputTruncated: false,
|
|
238
|
+
error: null,
|
|
239
|
+
process: null,
|
|
240
|
+
};
|
|
241
|
+
}),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
this.batches.set(batchId, batch);
|
|
245
|
+
for (const job of batch.jobs) this.batchQueue.push({ batchId, jobId: job.jobId });
|
|
246
|
+
this.#drainBatchQueue();
|
|
247
|
+
|
|
248
|
+
return this.#batchSummary(batch);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
batchStatus(batchId) {
|
|
252
|
+
this.#cleanup();
|
|
253
|
+
const batch = this.#getBatch(batchId);
|
|
254
|
+
return this.#batchSummary(batch);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
batchRead(batchId, maxChars = MAX_BATCH_READ_CHARS) {
|
|
258
|
+
this.#cleanup();
|
|
259
|
+
const batch = this.#getBatch(batchId);
|
|
260
|
+
const aggregateLimit = normalizeInteger(maxChars, MAX_BATCH_READ_CHARS, 1024, MAX_BATCH_READ_CHARS);
|
|
261
|
+
let remaining = aggregateLimit;
|
|
262
|
+
let truncated = false;
|
|
263
|
+
const jobs = batch.jobs.map((job) => {
|
|
264
|
+
const perJobLimit = Math.min(MAX_JOB_READ_CHARS, remaining);
|
|
265
|
+
const output = clipJobOutput(job.stdout, job.stderr, perJobLimit);
|
|
266
|
+
remaining = Math.max(0, remaining - output.stdout.length - output.stderr.length);
|
|
267
|
+
truncated ||= job.outputTruncated || output.truncated ||
|
|
268
|
+
((job.stdout.length + job.stderr.length) > (output.stdout.length + output.stderr.length));
|
|
269
|
+
return {
|
|
270
|
+
...this.#jobSummary(job),
|
|
271
|
+
stdout: output.stdout,
|
|
272
|
+
stderr: output.stderr,
|
|
273
|
+
outputTruncated: job.outputTruncated || output.truncated,
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
return { ...this.#batchSummary(batch), jobs, truncated, maxChars: aggregateLimit };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async batchCancel(batchId) {
|
|
280
|
+
this.#cleanup();
|
|
281
|
+
const batch = this.#getBatch(batchId);
|
|
282
|
+
batch.cancelled = true;
|
|
283
|
+
const cancelledAt = new Date().toISOString();
|
|
284
|
+
this.batchQueue = this.batchQueue.filter((queued) => {
|
|
285
|
+
if (queued.batchId !== batchId) return true;
|
|
286
|
+
const job = batch.jobs.find((item) => item.jobId === queued.jobId);
|
|
287
|
+
if (job && job.status === "queued") {
|
|
288
|
+
job.status = "cancelled";
|
|
289
|
+
job.endedAt = cancelledAt;
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const running = batch.jobs.filter((job) => job.status === "running" && job.process?.pid);
|
|
295
|
+
await Promise.all(running.map((job) => new Promise((resolve) => {
|
|
296
|
+
execFile("taskkill.exe", ["/PID", String(job.process.pid), "/T", "/F"], () => resolve());
|
|
297
|
+
})));
|
|
298
|
+
|
|
299
|
+
this.#finishBatchIfDone(batch);
|
|
300
|
+
this.#drainBatchQueue();
|
|
301
|
+
return this.#batchSummary(batch);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
getBatchConfig() {
|
|
305
|
+
return {
|
|
306
|
+
maxBatchSize: MAX_BATCH_SIZE,
|
|
307
|
+
batchConcurrency: this.batchConcurrency,
|
|
308
|
+
maxQueuedJobs: this.maxQueuedJobs,
|
|
309
|
+
activeBatchJobs: this.activeBatchJobs,
|
|
310
|
+
queuedBatchJobs: this.batchQueue.length,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#spawnSession({ type, command, cwd, args }) {
|
|
315
|
+
this.#cleanup();
|
|
316
|
+
const running = [...this.sessions.values()].filter((session) => session.status === "running").length;
|
|
317
|
+
if (running >= MAX_SESSIONS) throw new Error("too_many_sessions");
|
|
318
|
+
|
|
319
|
+
const child = spawn("powershell.exe", args, {
|
|
320
|
+
cwd,
|
|
321
|
+
windowsHide: true,
|
|
322
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const session = {
|
|
326
|
+
id: randomUUID(),
|
|
327
|
+
type,
|
|
328
|
+
command,
|
|
329
|
+
cwd,
|
|
330
|
+
pid: child.pid,
|
|
331
|
+
process: child,
|
|
332
|
+
status: "running",
|
|
333
|
+
exitCode: null,
|
|
334
|
+
signal: null,
|
|
335
|
+
startedAt: new Date().toISOString(),
|
|
336
|
+
endedAt: null,
|
|
337
|
+
chunks: [],
|
|
338
|
+
nextSeq: 1,
|
|
339
|
+
totalChars: 0,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
this.sessions.set(session.id, session);
|
|
343
|
+
child.stdout.on("data", (data) => this.#append(session, "stdout", data));
|
|
344
|
+
child.stderr.on("data", (data) => this.#append(session, "stderr", data));
|
|
345
|
+
child.on("error", (error) => this.#append(session, "stderr", error.message + os.EOL));
|
|
346
|
+
child.on("exit", (code, signal) => {
|
|
347
|
+
session.status = "exited";
|
|
348
|
+
session.exitCode = code;
|
|
349
|
+
session.signal = signal;
|
|
350
|
+
session.endedAt = new Date().toISOString();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
return this.#summary(session);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#drainBatchQueue() {
|
|
357
|
+
while (this.activeBatchJobs < this.batchConcurrency && this.batchQueue.length > 0) {
|
|
358
|
+
const next = this.batchQueue.findIndex((entry) => {
|
|
359
|
+
const batch = this.batches.get(entry.batchId);
|
|
360
|
+
if (!batch || batch.cancelled) return false;
|
|
361
|
+
const runningForBatch = batch.jobs.filter((job) => job.status === "running").length;
|
|
362
|
+
return runningForBatch < batch.requestedConcurrency;
|
|
363
|
+
});
|
|
364
|
+
if (next < 0) break;
|
|
365
|
+
|
|
366
|
+
const [{ batchId, jobId }] = this.batchQueue.splice(next, 1);
|
|
367
|
+
const batch = this.batches.get(batchId);
|
|
368
|
+
const job = batch?.jobs.find((item) => item.jobId === jobId);
|
|
369
|
+
if (!batch || !job || job.status !== "queued") continue;
|
|
370
|
+
this.#runBatchJob(batch, job);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#runBatchJob(batch, job) {
|
|
375
|
+
this.activeBatchJobs += 1;
|
|
376
|
+
job.status = "running";
|
|
377
|
+
job.startedAt = new Date().toISOString();
|
|
378
|
+
job.queueWaitMs = Date.now() - Date.parse(job.queuedAt);
|
|
379
|
+
|
|
380
|
+
const started = Date.now();
|
|
381
|
+
const child = execFile(
|
|
382
|
+
"powershell.exe",
|
|
383
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", job.command],
|
|
384
|
+
{
|
|
385
|
+
cwd: job.cwd,
|
|
386
|
+
timeout: job.timeoutMs,
|
|
387
|
+
windowsHide: true,
|
|
388
|
+
maxBuffer: MAX_EXEC_BUFFER,
|
|
389
|
+
},
|
|
390
|
+
(error, stdout, stderr) => {
|
|
391
|
+
job.process = null;
|
|
392
|
+
const clippedOutput = clipJobOutput(stdout, stderr);
|
|
393
|
+
job.stdout = clippedOutput.stdout;
|
|
394
|
+
job.stderr = clippedOutput.stderr;
|
|
395
|
+
job.outputTruncated = clippedOutput.truncated;
|
|
396
|
+
job.exitCode = typeof error?.code === "number" ? error.code : (error ? 1 : 0);
|
|
397
|
+
job.error = error ? error.message : null;
|
|
398
|
+
job.status = batch.cancelled ? "cancelled" : (error ? "failed" : "completed");
|
|
399
|
+
job.endedAt = new Date().toISOString();
|
|
400
|
+
job.durationMs = Date.now() - started;
|
|
401
|
+
this.activeBatchJobs = Math.max(0, this.activeBatchJobs - 1);
|
|
402
|
+
this.#finishBatchIfDone(batch);
|
|
403
|
+
this.#drainBatchQueue();
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
job.process = child;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#finishBatchIfDone(batch) {
|
|
410
|
+
if (batch.jobs.every((job) => ["completed", "failed", "cancelled"].includes(job.status))) {
|
|
411
|
+
batch.endedAt ||= new Date().toISOString();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
#jobSummary(job) {
|
|
416
|
+
return {
|
|
417
|
+
jobId: job.jobId,
|
|
418
|
+
index: job.index,
|
|
419
|
+
status: job.status,
|
|
420
|
+
queuedAt: job.queuedAt,
|
|
421
|
+
startedAt: job.startedAt,
|
|
422
|
+
endedAt: job.endedAt,
|
|
423
|
+
queueWaitMs: job.queueWaitMs,
|
|
424
|
+
durationMs: job.durationMs,
|
|
425
|
+
exitCode: job.exitCode,
|
|
426
|
+
error: job.error,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#batchSummary(batch) {
|
|
431
|
+
const counts = { queued: 0, running: 0, completed: 0, failed: 0, cancelled: 0 };
|
|
432
|
+
for (const job of batch.jobs) counts[job.status] = (counts[job.status] || 0) + 1;
|
|
433
|
+
return {
|
|
434
|
+
ok: true,
|
|
435
|
+
batchId: batch.batchId,
|
|
436
|
+
createdAt: batch.createdAt,
|
|
437
|
+
endedAt: batch.endedAt,
|
|
438
|
+
requestedConcurrency: batch.requestedConcurrency,
|
|
439
|
+
cancelled: batch.cancelled,
|
|
440
|
+
counts,
|
|
441
|
+
jobs: batch.jobs.map((job) => this.#jobSummary(job)),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#append(session, stream, data) {
|
|
446
|
+
const text = data.toString();
|
|
447
|
+
for (let offset = 0; offset < text.length; offset += 4096) {
|
|
448
|
+
const part = text.slice(offset, offset + 4096);
|
|
449
|
+
session.chunks.push({ seq: session.nextSeq++, stream, text: part });
|
|
450
|
+
session.totalChars += part.length;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
while (session.totalChars > MAX_SESSION_BUFFER && session.chunks.length > 1) {
|
|
454
|
+
const removed = session.chunks.shift();
|
|
455
|
+
session.totalChars -= removed.text.length;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#summary(session) {
|
|
460
|
+
return {
|
|
461
|
+
sessionId: session.id,
|
|
462
|
+
type: session.type,
|
|
463
|
+
command: session.command,
|
|
464
|
+
cwd: session.cwd,
|
|
465
|
+
pid: session.pid,
|
|
466
|
+
status: session.status,
|
|
467
|
+
exitCode: session.exitCode,
|
|
468
|
+
signal: session.signal,
|
|
469
|
+
startedAt: session.startedAt,
|
|
470
|
+
endedAt: session.endedAt,
|
|
471
|
+
nextSeq: session.nextSeq - 1,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#get(sessionId) {
|
|
476
|
+
if (typeof sessionId !== "string") throw new Error("invalid_session_id");
|
|
477
|
+
const session = this.sessions.get(sessionId);
|
|
478
|
+
if (!session) throw new Error("session_not_found");
|
|
479
|
+
return session;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#getBatch(batchId) {
|
|
483
|
+
if (typeof batchId !== "string") throw new Error("invalid_batch_id");
|
|
484
|
+
const batch = this.batches.get(batchId);
|
|
485
|
+
if (!batch) throw new Error("batch_not_found");
|
|
486
|
+
return batch;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
#cleanup() {
|
|
490
|
+
const now = Date.now();
|
|
491
|
+
for (const [id, session] of this.sessions) {
|
|
492
|
+
if (session.status === "running" || !session.endedAt) continue;
|
|
493
|
+
if (now - Date.parse(session.endedAt) > ENDED_TTL_MS) this.sessions.delete(id);
|
|
494
|
+
}
|
|
495
|
+
for (const [id, batch] of this.batches) {
|
|
496
|
+
if (!batch.endedAt) continue;
|
|
497
|
+
if (now - Date.parse(batch.endedAt) > ENDED_TTL_MS) this.batches.delete(id);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const retainedCompleted = [...this.batches.entries()]
|
|
501
|
+
.filter(([, batch]) => Boolean(batch.endedAt))
|
|
502
|
+
.sort((a, b) => Date.parse(b[1].endedAt) - Date.parse(a[1].endedAt));
|
|
503
|
+
for (const [id] of retainedCompleted.slice(MAX_RETAINED_BATCHES)) {
|
|
504
|
+
this.batches.delete(id);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
@@ -106,7 +106,29 @@ public static class ChatRelayNative {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
if ($Operation -eq "screenshot") {
|
|
109
|
-
$
|
|
109
|
+
$screens = @([System.Windows.Forms.Screen]::AllScreens)
|
|
110
|
+
if ($screens.Count -eq 0) { Fail "session_unavailable" }
|
|
111
|
+
|
|
112
|
+
$monitor = if ($null -ne $payload.monitor) { $payload.monitor } else { "primary" }
|
|
113
|
+
$screen = $null
|
|
114
|
+
$monitorIndex = -1
|
|
115
|
+
|
|
116
|
+
if ([string]$monitor -eq "primary") {
|
|
117
|
+
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
|
|
118
|
+
for ($i = 0; $i -lt $screens.Count; $i++) { if ($screens[$i].Primary) { $monitorIndex = $i; break } }
|
|
119
|
+
} elseif ([string]$monitor -eq "secondary") {
|
|
120
|
+
for ($i = 0; $i -lt $screens.Count; $i++) {
|
|
121
|
+
if (-not $screens[$i].Primary) { $screen = $screens[$i]; $monitorIndex = $i; break }
|
|
122
|
+
}
|
|
123
|
+
if ($null -eq $screen) { Fail "monitor_not_found" }
|
|
124
|
+
} else {
|
|
125
|
+
$parsedIndex = 0
|
|
126
|
+
if (-not [int]::TryParse([string]$monitor, [ref]$parsedIndex)) { Fail "invalid_monitor" }
|
|
127
|
+
if ($parsedIndex -lt 0 -or $parsedIndex -ge $screens.Count) { Fail "monitor_not_found" }
|
|
128
|
+
$screen = $screens[$parsedIndex]
|
|
129
|
+
$monitorIndex = $parsedIndex
|
|
130
|
+
}
|
|
131
|
+
|
|
110
132
|
if ($null -eq $screen) { Fail "session_unavailable" }
|
|
111
133
|
$bounds = $screen.Bounds
|
|
112
134
|
if ($bounds.Width -le 0 -or $bounds.Height -le 0) { Fail "session_unavailable" }
|
|
@@ -189,6 +211,9 @@ public static class ChatRelayNative {
|
|
|
189
211
|
desktopHeight = $bounds.Height
|
|
190
212
|
scaleX = $bounds.Width / [double]$finalWidth
|
|
191
213
|
scaleY = $bounds.Height / [double]$finalHeight
|
|
214
|
+
monitorIndex = $monitorIndex
|
|
215
|
+
isPrimary = [bool]$screen.Primary
|
|
216
|
+
deviceName = [string]$screen.DeviceName
|
|
192
217
|
byteLength = $bytes.Length
|
|
193
218
|
})
|
|
194
219
|
exit 0
|
package/package.json
CHANGED