@anusornneal/chat-relay 0.7.1 → 0.8.0
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 +30 -0
- package/agent/capability-scheduler.mjs +121 -0
- package/agent/connection-state.mjs +126 -0
- package/agent/desktop-manager.mjs +325 -68
- package/agent/file-manager.mjs +181 -23
- package/agent/lifecycle.mjs +101 -0
- package/agent/local-agent.mjs +273 -26
- package/agent/protocol.mjs +67 -0
- package/agent/terminal-manager.mjs +46 -3
- package/agent/windows-desktop-worker.ps1 +508 -0
- package/cli/index.mjs +41 -1
- package/cli/remote.mjs +51 -6
- package/package.json +73 -73
package/agent/file-manager.mjs
CHANGED
|
@@ -5,11 +5,32 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
const MAX_READ_BYTES = 256 * 1024;
|
|
6
6
|
const DEFAULT_READ_RESPONSE_BYTES = 32 * 1024;
|
|
7
7
|
const DEFAULT_MULTI_READ_RESPONSE_BYTES = 48 * 1024;
|
|
8
|
+
const DEFAULT_BATCH_RESPONSE_BYTES = 48 * 1024;
|
|
9
|
+
const MAX_BATCH_OPERATIONS = 20;
|
|
10
|
+
const MAX_ARTIFACT_BYTES = 40 * 1024;
|
|
11
|
+
const ARTIFACT_MIME_TYPES = new Map([
|
|
12
|
+
[".txt", "text/plain; charset=utf-8"],
|
|
13
|
+
[".log", "text/plain; charset=utf-8"],
|
|
14
|
+
[".md", "text/markdown; charset=utf-8"],
|
|
15
|
+
[".json", "application/json"],
|
|
16
|
+
[".png", "image/png"],
|
|
17
|
+
[".jpg", "image/jpeg"],
|
|
18
|
+
[".jpeg", "image/jpeg"],
|
|
19
|
+
]);
|
|
20
|
+
|
|
8
21
|
const MAX_READ_RESPONSE_BYTES = 48 * 1024;
|
|
9
22
|
const MIN_READ_RESPONSE_BYTES = 1024;
|
|
10
23
|
const MAX_WRITE_BYTES = 256 * 1024;
|
|
11
24
|
const MAX_SEARCH_FILES = 5000;
|
|
25
|
+
const DEFAULT_LIST_LIMIT = 200;
|
|
26
|
+
const MAX_LIST_LIMIT = 500;
|
|
27
|
+
const DEFAULT_LIST_RESPONSE_BYTES = 48 * 1024;
|
|
12
28
|
const SKIP_DIRS = new Set([".git", "node_modules", ".gradle", ".idea", ".wrangler"]);
|
|
29
|
+
const ALLOWED_DOT_DIRS = new Set([".github"]);
|
|
30
|
+
|
|
31
|
+
function skipWalkDirectory(name) {
|
|
32
|
+
return SKIP_DIRS.has(name) || (name.startsWith(".") && !ALLOWED_DOT_DIRS.has(name));
|
|
33
|
+
}
|
|
13
34
|
|
|
14
35
|
function splitRoots(spec) {
|
|
15
36
|
if (!spec) return [process.cwd()];
|
|
@@ -82,31 +103,48 @@ export class FileManager {
|
|
|
82
103
|
};
|
|
83
104
|
}
|
|
84
105
|
|
|
85
|
-
async list(input, depth = 1) {
|
|
106
|
+
async list(input, depth = 1, offset = 0, limit = DEFAULT_LIST_LIMIT, maxBytes = DEFAULT_LIST_RESPONSE_BYTES) {
|
|
86
107
|
const target = await this.#resolveExisting(input);
|
|
87
|
-
const
|
|
108
|
+
const numericDepth = Number(depth);
|
|
109
|
+
const maxDepth = Number.isFinite(numericDepth) ? Math.min(Math.max(Math.trunc(numericDepth), 0), 3) : 1;
|
|
110
|
+
const start = Math.max(Math.trunc(Number(offset) || 0), 0);
|
|
111
|
+
const pageLimit = Math.min(Math.max(Math.trunc(Number(limit) || DEFAULT_LIST_LIMIT), 1), MAX_LIST_LIMIT);
|
|
112
|
+
const responseBudget = boundedReadBytes(maxBytes, DEFAULT_LIST_RESPONSE_BYTES, 4 * 1024);
|
|
88
113
|
const entries = [];
|
|
89
|
-
await this.#walkList(target, 0, maxDepth, entries);
|
|
90
|
-
|
|
114
|
+
await this.#walkList(target, 0, maxDepth, entries, start + pageLimit + 1);
|
|
115
|
+
|
|
116
|
+
const selected = entries.slice(start, start + pageLimit);
|
|
117
|
+
const payloadFor = (items) => {
|
|
118
|
+
const nextOffset = start + items.length;
|
|
119
|
+
const hasMore = nextOffset < entries.length;
|
|
120
|
+
return {
|
|
121
|
+
path: target,
|
|
122
|
+
entries: items,
|
|
123
|
+
offset: start,
|
|
124
|
+
limit: pageLimit,
|
|
125
|
+
maxBytes: responseBudget,
|
|
126
|
+
truncated: hasMore,
|
|
127
|
+
nextOffset: hasMore ? nextOffset : null,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
while (selected.length > 1 && byteSize(payloadFor(selected)) > responseBudget) selected.pop();
|
|
131
|
+
if (selected.length === 1 && byteSize(payloadFor(selected)) > responseBudget) throw new Error("list_entry_too_large");
|
|
132
|
+
|
|
133
|
+
return payloadFor(selected);
|
|
91
134
|
}
|
|
92
135
|
|
|
93
|
-
async #walkList(current, level, maxDepth, output) {
|
|
136
|
+
async #walkList(current, level, maxDepth, output, stopAfter = 1001) {
|
|
94
137
|
const items = await fs.readdir(current, { withFileTypes: true });
|
|
95
138
|
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
96
139
|
for (const item of items) {
|
|
97
140
|
const fullPath = path.join(current, item.name);
|
|
98
141
|
let size = null;
|
|
99
142
|
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;
|
|
143
|
+
output.push({ path: fullPath, name: item.name, type: item.isDirectory() ? "directory" : item.isFile() ? "file" : "other", size });
|
|
144
|
+
if (output.length >= stopAfter) return;
|
|
145
|
+
if (item.isDirectory() && level < maxDepth && !skipWalkDirectory(item.name)) {
|
|
146
|
+
await this.#walkList(fullPath, level + 1, maxDepth, output, stopAfter);
|
|
147
|
+
if (output.length >= stopAfter) return;
|
|
110
148
|
}
|
|
111
149
|
}
|
|
112
150
|
}
|
|
@@ -230,6 +268,127 @@ export class FileManager {
|
|
|
230
268
|
};
|
|
231
269
|
}
|
|
232
270
|
|
|
271
|
+
|
|
272
|
+
async batch(operations, maxTotalBytes = DEFAULT_BATCH_RESPONSE_BYTES) {
|
|
273
|
+
if (!Array.isArray(operations) || operations.length === 0 || operations.length > MAX_BATCH_OPERATIONS) {
|
|
274
|
+
throw new Error("invalid_batch");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const totalBudget = boundedReadBytes(maxTotalBytes, DEFAULT_BATCH_RESPONSE_BYTES, 4 * 1024);
|
|
278
|
+
const results = [];
|
|
279
|
+
let nextIndex = null;
|
|
280
|
+
|
|
281
|
+
for (let index = 0; index < operations.length; index++) {
|
|
282
|
+
const operation = operations[index] || {};
|
|
283
|
+
const op = String(operation.op || "");
|
|
284
|
+
const base = {
|
|
285
|
+
index,
|
|
286
|
+
...(typeof operation.id === "string" && operation.id ? { id: operation.id.slice(0, 128) } : {}),
|
|
287
|
+
op,
|
|
288
|
+
...(typeof operation.path === "string" ? { path: operation.path } : {}),
|
|
289
|
+
};
|
|
290
|
+
const minimumBudget = op === "list" ? 4 * 1024 : MIN_READ_RESPONSE_BYTES;
|
|
291
|
+
const fallbackBudget = op === "list" ? DEFAULT_LIST_RESPONSE_BYTES : DEFAULT_READ_RESPONSE_BYTES;
|
|
292
|
+
let operationBudget = boundedReadBytes(operation.maxBytes, fallbackBudget, minimumBudget);
|
|
293
|
+
let candidate;
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
if (typeof operation.path !== "string" || operation.path.length === 0) {
|
|
297
|
+
throw new Error("invalid_path");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
301
|
+
let result;
|
|
302
|
+
if (op === "stat") {
|
|
303
|
+
result = await this.stat(operation.path);
|
|
304
|
+
} else if (op === "read") {
|
|
305
|
+
result = await this.read(operation.path, operation.offset, operation.length, operationBudget);
|
|
306
|
+
} else if (op === "list") {
|
|
307
|
+
result = await this.list(operation.path, operation.depth, operation.offset, operation.limit, operationBudget);
|
|
308
|
+
} else {
|
|
309
|
+
throw new Error("invalid_batch_operation");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
candidate = { ...base, ok: true, result };
|
|
313
|
+
const projected = {
|
|
314
|
+
results: [...results, candidate],
|
|
315
|
+
totalOperations: operations.length,
|
|
316
|
+
nextIndex: index + 1 < operations.length ? index + 1 : null,
|
|
317
|
+
truncated: false,
|
|
318
|
+
maxTotalBytes: totalBudget,
|
|
319
|
+
};
|
|
320
|
+
if (byteSize(projected) <= totalBudget) break;
|
|
321
|
+
if (op === "stat") {
|
|
322
|
+
candidate = undefined;
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
operationBudget = Math.floor(operationBudget * 0.65);
|
|
327
|
+
candidate = undefined;
|
|
328
|
+
if (operationBudget < minimumBudget) break;
|
|
329
|
+
}
|
|
330
|
+
} catch (error) {
|
|
331
|
+
candidate = {
|
|
332
|
+
...base,
|
|
333
|
+
ok: false,
|
|
334
|
+
error: error instanceof Error ? error.message : "batch_operation_failed",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!candidate) {
|
|
339
|
+
nextIndex = index;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const projected = {
|
|
344
|
+
results: [...results, candidate],
|
|
345
|
+
totalOperations: operations.length,
|
|
346
|
+
nextIndex: index + 1 < operations.length ? index + 1 : null,
|
|
347
|
+
truncated: false,
|
|
348
|
+
maxTotalBytes: totalBudget,
|
|
349
|
+
};
|
|
350
|
+
if (byteSize(projected) > totalBudget) {
|
|
351
|
+
nextIndex = index;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
results.push(candidate);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const nestedTruncated = results.some((entry) => entry.ok && entry.result?.truncated === true);
|
|
358
|
+
return {
|
|
359
|
+
results,
|
|
360
|
+
totalOperations: operations.length,
|
|
361
|
+
nextIndex,
|
|
362
|
+
truncated: nextIndex !== null || nestedTruncated,
|
|
363
|
+
maxTotalBytes: totalBudget,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
async exportArtifact(input, maxBytes = MAX_ARTIFACT_BYTES) {
|
|
369
|
+
const target = await this.#resolveExisting(input);
|
|
370
|
+
const info = await fs.stat(target);
|
|
371
|
+
if (!info.isFile()) throw new Error("not_a_file");
|
|
372
|
+
|
|
373
|
+
const requested = Number(maxBytes);
|
|
374
|
+
const byteLimit = Number.isFinite(requested)
|
|
375
|
+
? Math.min(Math.max(Math.trunc(requested), 1024), MAX_ARTIFACT_BYTES)
|
|
376
|
+
: MAX_ARTIFACT_BYTES;
|
|
377
|
+
if (info.size <= 0) throw new Error("artifact_empty");
|
|
378
|
+
if (info.size > byteLimit) throw new Error("artifact_too_large");
|
|
379
|
+
|
|
380
|
+
const bytes = await fs.readFile(target);
|
|
381
|
+
const extension = path.extname(target).toLowerCase();
|
|
382
|
+
const mimeType = ARTIFACT_MIME_TYPES.get(extension) || "application/octet-stream";
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
filename: path.basename(target).slice(0, 180),
|
|
386
|
+
mimeType,
|
|
387
|
+
byteLength: bytes.byteLength,
|
|
388
|
+
data: bytes.toString("base64"),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
233
392
|
async write(input, content, mode = "rewrite") {
|
|
234
393
|
if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
|
|
235
394
|
throw new Error("content_too_large");
|
|
@@ -305,12 +464,14 @@ export class FileManager {
|
|
|
305
464
|
const needle = pattern.toLowerCase();
|
|
306
465
|
let scanned = 0;
|
|
307
466
|
|
|
308
|
-
const
|
|
309
|
-
|
|
467
|
+
const queue = [target];
|
|
468
|
+
while (queue.length > 0 && session.results.length < limit && scanned < MAX_SEARCH_FILES) {
|
|
469
|
+
const current = queue.shift();
|
|
310
470
|
const items = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
471
|
+
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
311
472
|
for (const item of items) {
|
|
312
473
|
if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) break;
|
|
313
|
-
if (item.isDirectory() &&
|
|
474
|
+
if (item.isDirectory() && skipWalkDirectory(item.name)) continue;
|
|
314
475
|
const fullPath = path.join(current, item.name);
|
|
315
476
|
scanned++;
|
|
316
477
|
|
|
@@ -330,12 +491,9 @@ export class FileManager {
|
|
|
330
491
|
}
|
|
331
492
|
}
|
|
332
493
|
}
|
|
333
|
-
|
|
334
|
-
if (item.isDirectory()) await walk(fullPath);
|
|
494
|
+
if (item.isDirectory()) queue.push(fullPath);
|
|
335
495
|
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
await walk(target);
|
|
496
|
+
}
|
|
339
497
|
session.status = "COMPLETED";
|
|
340
498
|
session.scanned = scanned;
|
|
341
499
|
} catch (error) {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const ALLOWED_WHILE_DRAINING = new Set([
|
|
2
|
+
"ping",
|
|
3
|
+
"agent.config",
|
|
4
|
+
"agent.recentCalls",
|
|
5
|
+
"agent.lifecycle.status",
|
|
6
|
+
"agent.lifecycle.drain",
|
|
7
|
+
"agent.lifecycle.resume",
|
|
8
|
+
"agent.lifecycle.restart",
|
|
9
|
+
"fs.stat",
|
|
10
|
+
"fs.list",
|
|
11
|
+
"fs.read",
|
|
12
|
+
"fs.readMany",
|
|
13
|
+
"fs.batch",
|
|
14
|
+
"fs.artifact",
|
|
15
|
+
"fs.search.start",
|
|
16
|
+
"fs.search.results",
|
|
17
|
+
"process.list",
|
|
18
|
+
"process.kill",
|
|
19
|
+
"desktop.screenshot",
|
|
20
|
+
"desktop.clipboard.read",
|
|
21
|
+
"desktop.window.list",
|
|
22
|
+
"terminal.batch.status",
|
|
23
|
+
"terminal.batch.read",
|
|
24
|
+
"terminal.batch.cancel",
|
|
25
|
+
"terminal.read",
|
|
26
|
+
"terminal.write",
|
|
27
|
+
"terminal.kill",
|
|
28
|
+
"terminal.list",
|
|
29
|
+
"terminal.observability",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export const AGENT_RESTART_EXIT_CODE = 4;
|
|
33
|
+
|
|
34
|
+
function normalizeWork(value = {}) {
|
|
35
|
+
const count = (key) => Math.max(0, Number(value[key]) || 0);
|
|
36
|
+
const work = {
|
|
37
|
+
activeSessions: count("activeSessions"),
|
|
38
|
+
activeBatchJobs: count("activeBatchJobs"),
|
|
39
|
+
queuedBatchJobs: count("queuedBatchJobs"),
|
|
40
|
+
activeTerminalExecs: count("activeTerminalExecs"),
|
|
41
|
+
queuedTerminalExecs: count("queuedTerminalExecs"),
|
|
42
|
+
};
|
|
43
|
+
work.total = Object.values(work).reduce((sum, item) => sum + item, 0);
|
|
44
|
+
return work;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class AgentLifecycle {
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
this.state = "running";
|
|
50
|
+
this.drainStartedAt = null;
|
|
51
|
+
this.restartRequestedAt = null;
|
|
52
|
+
this.now = options.now ?? (() => Date.now());
|
|
53
|
+
this.workSummary = options.workSummary ?? (() => ({}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
snapshot() {
|
|
57
|
+
const work = normalizeWork(this.workSummary());
|
|
58
|
+
return {
|
|
59
|
+
state: this.state,
|
|
60
|
+
drainStartedAt: this.drainStartedAt,
|
|
61
|
+
restartRequestedAt: this.restartRequestedAt,
|
|
62
|
+
readyToRestart: this.state === "draining" && work.total === 0,
|
|
63
|
+
work,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
drain() {
|
|
68
|
+
if (this.state !== "restart-pending") {
|
|
69
|
+
this.state = "draining";
|
|
70
|
+
this.drainStartedAt ??= new Date(this.now()).toISOString();
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, lifecycle: this.snapshot() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
resume() {
|
|
76
|
+
if (this.state === "restart-pending") {
|
|
77
|
+
return { ok: false, error: "restart_pending", lifecycle: this.snapshot() };
|
|
78
|
+
}
|
|
79
|
+
this.state = "running";
|
|
80
|
+
this.drainStartedAt = null;
|
|
81
|
+
return { ok: true, lifecycle: this.snapshot() };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
guard(action) {
|
|
85
|
+
if (this.state === "running" || ALLOWED_WHILE_DRAINING.has(String(action))) return null;
|
|
86
|
+
return { ok: false, error: "agent_draining", lifecycle: this.snapshot() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
requestRestart() {
|
|
90
|
+
if (this.state !== "draining") {
|
|
91
|
+
return { ok: false, error: "drain_required", lifecycle: this.snapshot() };
|
|
92
|
+
}
|
|
93
|
+
const snapshot = this.snapshot();
|
|
94
|
+
if (snapshot.work.total > 0) {
|
|
95
|
+
return { ok: false, error: "active_work_remaining", lifecycle: snapshot };
|
|
96
|
+
}
|
|
97
|
+
this.state = "restart-pending";
|
|
98
|
+
this.restartRequestedAt = new Date(this.now()).toISOString();
|
|
99
|
+
return { ok: true, restart: true, lifecycle: this.snapshot() };
|
|
100
|
+
}
|
|
101
|
+
}
|