@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 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 result = await this.runner("screenshot", {});
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
  }
@@ -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 ENDED_TTL_MS = 30 * 60 * 1000;
11
-
12
- const BLOCKED_COMMANDS = [
13
- /\b(mkfs|format|mount|umount|fdisk|dd|parted|diskpart|sudo|su|passwd)\b/i,
14
- /\b(adduser|useradd|usermod|groupadd|chsh|visudo|shutdown|reboot|halt)\b/i,
15
- /\b(poweroff|init|iptables|firewall|netsh|sfc|bcdedit|runas|cipher|takeown)\b/i,
16
- /\b(reg|sc)\.exe\b/i,
17
- ];
18
-
19
- function validateCommand(command) {
20
- if (typeof command !== "string" || command.length === 0 || command.length > 4000) {
21
- throw new Error("invalid_command");
22
- }
23
- if (BLOCKED_COMMANDS.some((pattern) => pattern.test(command))) {
24
- throw new Error("command_blocked");
25
- }
26
- }
27
-
28
- function normalizeTimeout(timeoutMs, fallback = 15000, max = 20000) {
29
- const value = Number(timeoutMs);
30
- if (!Number.isFinite(value)) return fallback;
31
- return Math.min(Math.max(Math.trunc(value), 1000), max);
32
- }
33
-
34
- function safeCwd(cwd) {
35
- if (cwd === undefined || cwd === null || cwd === "") return process.cwd();
36
- if (typeof cwd !== "string" || cwd.length > 1024) throw new Error("invalid_cwd");
37
- return cwd;
38
- }
39
-
40
- export class TerminalManager {
41
- constructor() {
42
- this.sessions = new Map();
43
- }
44
-
45
- async exec(command, cwd, timeoutMs) {
46
- validateCommand(command);
47
- const resolvedCwd = safeCwd(cwd);
48
- const timeout = normalizeTimeout(timeoutMs);
49
-
50
- return new Promise((resolve) => {
51
- execFile(
52
- "powershell.exe",
53
- ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command],
54
- {
55
- cwd: resolvedCwd,
56
- timeout,
57
- windowsHide: true,
58
- maxBuffer: MAX_EXEC_BUFFER,
59
- },
60
- (error, stdout, stderr) => {
61
- resolve({
62
- ok: !error,
63
- exitCode: typeof error?.code === "number" ? error.code : (error ? 1 : 0),
64
- stdout: stdout ?? "",
65
- stderr: stderr ?? "",
66
- error: error ? error.message : null,
67
- });
68
- },
69
- );
70
- });
71
- }
72
-
73
- start(command, cwd) {
74
- validateCommand(command);
75
- return this.#spawnSession({
76
- type: "command",
77
- command,
78
- cwd: safeCwd(cwd),
79
- args: ["-NoLogo", "-NoProfile", "-Command", command],
80
- });
81
- }
82
-
83
- startShell(cwd) {
84
- return this.#spawnSession({
85
- type: "shell",
86
- command: "powershell",
87
- cwd: safeCwd(cwd),
88
- args: ["-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
89
- });
90
- }
91
-
92
- list() {
93
- this.#cleanup();
94
- return [...this.sessions.values()].map((session) => this.#summary(session));
95
- }
96
-
97
- read(sessionId, afterSeq = 0, maxChars = MAX_READ_CHARS) {
98
- this.#cleanup();
99
- const session = this.#get(sessionId);
100
- const limit = Math.min(Math.max(Number(maxChars) || MAX_READ_CHARS, 1024), MAX_READ_CHARS);
101
- const oldestSeq = session.chunks[0]?.seq ?? session.nextSeq;
102
- const chunks = [];
103
- let chars = 0;
104
-
105
- for (const chunk of session.chunks) {
106
- if (chunk.seq <= afterSeq) continue;
107
- if (chunks.length > 0 && chars + chunk.text.length > limit) break;
108
- chunks.push(chunk);
109
- chars += chunk.text.length;
110
- }
111
-
112
- return {
113
- ...this.#summary(session),
114
- chunks,
115
- oldestSeq,
116
- nextSeq: chunks.at(-1)?.seq ?? afterSeq,
117
- truncated: afterSeq < oldestSeq - 1,
118
- };
119
- }
120
-
121
- write(sessionId, input, appendNewline = true) {
122
- const session = this.#get(sessionId);
123
- if (session.status !== "running") throw new Error("session_not_running");
124
- if (typeof input !== "string" || input.length === 0 || input.length > MAX_INPUT_CHARS) {
125
- throw new Error("invalid_input");
126
- }
127
- session.process.stdin.write(input + (appendNewline ? os.EOL : ""));
128
- return this.#summary(session);
129
- }
130
-
131
- async kill(sessionId) {
132
- const session = this.#get(sessionId);
133
- if (session.status !== "running") return this.#summary(session);
134
-
135
- await new Promise((resolve) => {
136
- execFile("taskkill.exe", ["/PID", String(session.pid), "/T", "/F"], () => resolve());
137
- });
138
- return this.#summary(session);
139
- }
140
-
141
- #spawnSession({ type, command, cwd, args }) {
142
- this.#cleanup();
143
- const running = [...this.sessions.values()].filter((session) => session.status === "running").length;
144
- if (running >= MAX_SESSIONS) throw new Error("too_many_sessions");
145
-
146
- const child = spawn("powershell.exe", args, {
147
- cwd,
148
- windowsHide: true,
149
- stdio: ["pipe", "pipe", "pipe"],
150
- });
151
-
152
- const session = {
153
- id: randomUUID(),
154
- type,
155
- command,
156
- cwd,
157
- pid: child.pid,
158
- process: child,
159
- status: "running",
160
- exitCode: null,
161
- signal: null,
162
- startedAt: new Date().toISOString(),
163
- endedAt: null,
164
- chunks: [],
165
- nextSeq: 1,
166
- totalChars: 0,
167
- };
168
-
169
- this.sessions.set(session.id, session);
170
- child.stdout.on("data", (data) => this.#append(session, "stdout", data));
171
- child.stderr.on("data", (data) => this.#append(session, "stderr", data));
172
- child.on("error", (error) => this.#append(session, "stderr", error.message + os.EOL));
173
- child.on("exit", (code, signal) => {
174
- session.status = "exited";
175
- session.exitCode = code;
176
- session.signal = signal;
177
- session.endedAt = new Date().toISOString();
178
- });
179
-
180
- return this.#summary(session);
181
- }
182
-
183
- #append(session, stream, data) {
184
- const text = data.toString();
185
- for (let offset = 0; offset < text.length; offset += 4096) {
186
- const part = text.slice(offset, offset + 4096);
187
- session.chunks.push({ seq: session.nextSeq++, stream, text: part });
188
- session.totalChars += part.length;
189
- }
190
-
191
- while (session.totalChars > MAX_SESSION_BUFFER && session.chunks.length > 1) {
192
- const removed = session.chunks.shift();
193
- session.totalChars -= removed.text.length;
194
- }
195
- }
196
-
197
- #summary(session) {
198
- return {
199
- sessionId: session.id,
200
- type: session.type,
201
- command: session.command,
202
- cwd: session.cwd,
203
- pid: session.pid,
204
- status: session.status,
205
- exitCode: session.exitCode,
206
- signal: session.signal,
207
- startedAt: session.startedAt,
208
- endedAt: session.endedAt,
209
- nextSeq: session.nextSeq - 1,
210
- };
211
- }
212
-
213
- #get(sessionId) {
214
- if (typeof sessionId !== "string") throw new Error("invalid_session_id");
215
- const session = this.sessions.get(sessionId);
216
- if (!session) throw new Error("session_not_found");
217
- return session;
218
- }
219
-
220
- #cleanup() {
221
- const now = Date.now();
222
- for (const [id, session] of this.sessions) {
223
- if (session.status === "running" || !session.endedAt) continue;
224
- if (now - Date.parse(session.endedAt) > ENDED_TTL_MS) {
225
- this.sessions.delete(id);
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
- $screen = [System.Windows.Forms.Screen]::PrimaryScreen
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anusornneal/chat-relay",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Zero-checkout remote MCP agent for ChatGPT with terminal, filesystem, process, multi-user, and multi-agent support.",
5
5
  "type": "module",
6
6
  "bin": {