@anusornneal/chat-relay 0.7.2 → 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 +440 -319
- package/agent/file-manager.mjs +150 -5
- package/agent/lifecycle.mjs +101 -0
- package/agent/local-agent.mjs +259 -26
- package/agent/protocol.mjs +67 -0
- package/agent/windows-desktop-worker.ps1 +508 -380
- package/cli/index.mjs +41 -1
- package/cli/remote.mjs +51 -6
- package/package.json +1 -1
package/agent/file-manager.mjs
CHANGED
|
@@ -5,6 +5,19 @@ 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;
|
|
@@ -101,12 +114,23 @@ export class FileManager {
|
|
|
101
114
|
await this.#walkList(target, 0, maxDepth, entries, start + pageLimit + 1);
|
|
102
115
|
|
|
103
116
|
const selected = entries.slice(start, start + pageLimit);
|
|
104
|
-
|
|
105
|
-
|
|
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");
|
|
106
132
|
|
|
107
|
-
|
|
108
|
-
const hasMore = nextOffset < entries.length;
|
|
109
|
-
return { path: target, entries: selected, offset: start, limit: pageLimit, maxBytes: responseBudget, truncated: hasMore, nextOffset: hasMore ? nextOffset : null };
|
|
133
|
+
return payloadFor(selected);
|
|
110
134
|
}
|
|
111
135
|
|
|
112
136
|
async #walkList(current, level, maxDepth, output, stopAfter = 1001) {
|
|
@@ -244,6 +268,127 @@ export class FileManager {
|
|
|
244
268
|
};
|
|
245
269
|
}
|
|
246
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
|
+
|
|
247
392
|
async write(input, content, mode = "rewrite") {
|
|
248
393
|
if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
|
|
249
394
|
throw new Error("content_too_large");
|
|
@@ -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
|
+
}
|