@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.
@@ -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 maxDepth = Math.min(Math.max(Number(depth) || 1, 0), 3);
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
- return { path: target, entries };
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
- path: fullPath,
102
- name: item.name,
103
- type: item.isDirectory() ? "directory" : item.isFile() ? "file" : "other",
104
- size,
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 walk = async (current) => {
309
- if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) return;
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() && SKIP_DIRS.has(item.name)) continue;
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
+ }