@anusornneal/chat-relay 0.6.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.
@@ -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
+ }