@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.
@@ -1,8 +1,12 @@
1
1
  import WebSocket from "ws";
2
- import { DesktopManager } from "./desktop-manager.mjs";
2
+ import { DesktopManager, createDesktopPlatformAdapter } from "./desktop-manager.mjs";
3
3
  import { FileManager } from "./file-manager.mjs";
4
4
  import { ProcessManager } from "./process-manager.mjs";
5
5
  import { TerminalManager } from "./terminal-manager.mjs";
6
+ import { CapabilityScheduler } from "./capability-scheduler.mjs";
7
+ import { AgentConnectionState } from "./connection-state.mjs";
8
+ import { buildAgentHello } from "./protocol.mjs";
9
+ import { AgentLifecycle, AGENT_RESTART_EXIT_CODE } from "./lifecycle.mjs";
6
10
 
7
11
  const relayUrl = process.env.RELAY_URL;
8
12
  const agentToken = process.env.AGENT_TOKEN;
@@ -11,16 +15,59 @@ const agentName = process.env.AGENT_NAME || agentId;
11
15
  const reconnectMs = Number(process.env.RECONNECT_MS ?? 2000);
12
16
  const terminalEnabled = process.env.TERMINAL_ENABLED === "1";
13
17
  const desktopEnabled = process.env.DESKTOP_ENABLED === "1";
18
+ const desktopAdapter = createDesktopPlatformAdapter({ platform: process.platform });
19
+ const agentHello = buildAgentHello({
20
+ platform: desktopAdapter.platform,
21
+ terminalEnabled,
22
+ desktopEnabled: desktopEnabled && desktopAdapter.supported,
23
+ });
14
24
  const MAX_RESPONSE_BYTES = 60 * 1024;
15
25
  const terminals = new TerminalManager({
16
26
  batchConcurrency: process.env.TERMINAL_BATCH_CONCURRENCY,
17
27
  maxQueuedJobs: process.env.TERMINAL_BATCH_MAX_QUEUED,
18
28
  });
29
+ const scheduler = new CapabilityScheduler({
30
+ maxQueued: process.env.AGENT_MAX_QUEUED,
31
+ queueTimeoutMs: process.env.AGENT_QUEUE_TIMEOUT_MS,
32
+ fileConcurrency: process.env.AGENT_FILE_CONCURRENCY,
33
+ processConcurrency: process.env.AGENT_PROCESS_CONCURRENCY,
34
+ terminalExecConcurrency: process.env.AGENT_TERMINAL_EXEC_CONCURRENCY,
35
+ terminalControlConcurrency: process.env.AGENT_TERMINAL_CONTROL_CONCURRENCY,
36
+ desktopReadConcurrency: process.env.AGENT_DESKTOP_READ_CONCURRENCY,
37
+ });
38
+ const connectionState = new AgentConnectionState({
39
+ baseReconnectMs: reconnectMs,
40
+ maxReconnectMs: process.env.RECONNECT_MAX_MS,
41
+ heartbeatMs: process.env.AGENT_HEARTBEAT_MS,
42
+ });
19
43
  const files = new FileManager(process.env.ALLOWED_ROOTS);
20
44
  const processes = new ProcessManager();
21
- const desktop = new DesktopManager({ enabled: desktopEnabled });
45
+ const desktop = new DesktopManager({
46
+ enabled: desktopEnabled,
47
+ adapter: desktopAdapter,
48
+ controlMaxQueued: process.env.DESKTOP_CONTROL_MAX_QUEUED,
49
+ controlQueueTimeoutMs: process.env.AGENT_QUEUE_TIMEOUT_MS,
50
+ });
22
51
  const recentCalls = [];
23
52
  let reauthorizationRequired = false;
53
+ let activeSocket = null;
54
+ let reconnectTimer = null;
55
+ let heartbeatTimer = null;
56
+ let stopping = false;
57
+
58
+ function currentWorkSummary() {
59
+ const terminal = terminals.observability();
60
+ const terminalExec = scheduler.snapshot().terminalExec || {};
61
+ return {
62
+ activeSessions: terminal.sessions.filter((item) => item.status === "running").length,
63
+ activeBatchJobs: terminal.activeExecJobs,
64
+ queuedBatchJobs: terminal.queuedJobs,
65
+ activeTerminalExecs: terminalExec.active,
66
+ queuedTerminalExecs: terminalExec.queued,
67
+ };
68
+ }
69
+
70
+ const lifecycle = new AgentLifecycle({ workSummary: currentWorkSummary });
24
71
 
25
72
  if (!relayUrl || !agentToken) {
26
73
  console.error("RELAY_URL and AGENT_TOKEN are required");
@@ -30,8 +77,8 @@ if (!relayUrl || !agentToken) {
30
77
  const wsUrl = relayUrl.replace(/^http/, "ws").replace(/\/$/, "") +
31
78
  `/agent?agentId=${encodeURIComponent(agentId)}`;
32
79
 
33
- function serializeResponse(requestId, payload) {
34
- const response = JSON.stringify({ requestId, payload });
80
+ function serializeResponse(requestId, payload, meta) {
81
+ const response = JSON.stringify({ requestId, payload, ...(meta ? { meta } : {}) });
35
82
  if (Buffer.byteLength(response, "utf8") <= MAX_RESPONSE_BYTES) return response;
36
83
  return JSON.stringify({
37
84
  requestId,
@@ -45,8 +92,11 @@ function serializeResponse(requestId, payload) {
45
92
 
46
93
  async function handlePayload(payload) {
47
94
  if (!payload || typeof payload !== "object") return payload;
95
+ const action = String(payload.action ?? "");
96
+ const lifecycleGate = lifecycle.guard(action);
97
+ if (lifecycleGate) return lifecycleGate;
48
98
 
49
- switch (payload.action) {
99
+ switch (action) {
50
100
  case "ping":
51
101
  return { ok: true, action: "pong", at: new Date().toISOString(), agentId, agentName };
52
102
 
@@ -56,12 +106,37 @@ async function handlePayload(payload) {
56
106
  agentName,
57
107
  terminalEnabled,
58
108
  terminalBatch: terminals.getBatchConfig(),
109
+ concurrency: scheduler.snapshot(),
59
110
  desktop: desktop.getConfig(),
60
111
  allowedRoots: files.getRoots(),
61
- reconnectMs,
112
+ reconnectMs: connectionState.baseReconnectMs,
113
+ connection: { ...connectionState.snapshot(), processId: process.pid },
114
+ lifecycle: lifecycle.snapshot(),
115
+ terminalContinuity: {
116
+ processId: process.pid,
117
+ activeSessions: terminals.list().filter((session) => session.status === "running").length,
118
+ },
119
+ protocol: {
120
+ protocolVersion: agentHello.protocolVersion,
121
+ agentVersion: agentHello.agentVersion,
122
+ platform: agentHello.platform,
123
+ arch: agentHello.arch,
124
+ capabilities: agentHello.capabilities,
125
+ },
62
126
  });
63
127
  case "agent.recentCalls":
64
128
  return recentCalls.slice(-(Math.min(Math.max(Number(payload.limit) || 50, 1), 100)));
129
+ case "agent.lifecycle.status":
130
+ return { ok: true, lifecycle: lifecycle.snapshot() };
131
+ case "agent.lifecycle.drain":
132
+ return lifecycle.drain();
133
+ case "agent.lifecycle.resume":
134
+ return lifecycle.resume();
135
+ case "agent.lifecycle.restart": {
136
+ const result = lifecycle.requestRestart();
137
+ if (result.ok) setTimeout(restartAgentProcess, 150);
138
+ return result;
139
+ }
65
140
 
66
141
  case "fs.stat":
67
142
  return files.stat(payload.path);
@@ -71,6 +146,10 @@ async function handlePayload(payload) {
71
146
  return files.read(payload.path, payload.offset, payload.length, payload.maxBytes);
72
147
  case "fs.readMany":
73
148
  return files.readMany(payload.paths, payload.maxTotalBytes);
149
+ case "fs.batch":
150
+ return files.batch(payload.operations, payload.maxTotalBytes);
151
+ case "fs.artifact":
152
+ return files.exportArtifact(payload.path, payload.maxBytes);
74
153
  case "fs.write":
75
154
  return files.write(payload.path, payload.content, payload.mode);
76
155
  case "fs.edit":
@@ -93,6 +172,14 @@ async function handlePayload(payload) {
93
172
 
94
173
  case "desktop.screenshot":
95
174
  return desktop.screenshot(payload);
175
+ case "desktop.clipboard.read":
176
+ return desktop.clipboardRead(payload);
177
+ case "desktop.clipboard.write":
178
+ return desktop.clipboardWrite(payload);
179
+ case "desktop.window.list":
180
+ return desktop.listWindows(payload);
181
+ case "desktop.window.focus":
182
+ return desktop.focusWindow(payload);
96
183
  case "desktop.mouse.click":
97
184
  return desktop.mouseClick(payload);
98
185
  case "desktop.keyboard.input":
@@ -142,27 +229,120 @@ async function handlePayload(payload) {
142
229
  }
143
230
  }
144
231
 
145
- function requireReauthorization() {
232
+ function clearReconnectTimer() {
233
+ if (!reconnectTimer) return;
234
+ clearTimeout(reconnectTimer);
235
+ reconnectTimer = null;
236
+ }
237
+
238
+ function stopHeartbeat() {
239
+ if (!heartbeatTimer) return;
240
+ clearInterval(heartbeatTimer);
241
+ heartbeatTimer = null;
242
+ }
243
+
244
+ function sendHeartbeat(socket) {
245
+ if (socket !== activeSocket || socket.readyState !== WebSocket.OPEN) return;
246
+ const now = Date.now();
247
+ try {
248
+ socket.send(JSON.stringify({
249
+ control: "agent_heartbeat",
250
+ at: now,
251
+ agentId,
252
+ processId: process.pid,
253
+ heartbeatMs: connectionState.heartbeatMs,
254
+ lifecycle: lifecycle.snapshot(),
255
+ health: {
256
+ reconnectCount: connectionState.reconnectCount,
257
+ reconnectAttempt: connectionState.reconnectAttempt,
258
+ lastDisconnectReason: connectionState.lastDisconnectReason,
259
+ queues: scheduler.snapshot(),
260
+ },
261
+ }));
262
+ connectionState.markHeartbeat(now);
263
+ } catch {}
264
+ }
265
+
266
+ function startHeartbeat(socket) {
267
+ stopHeartbeat();
268
+ sendHeartbeat(socket);
269
+ heartbeatTimer = setInterval(() => sendHeartbeat(socket), connectionState.heartbeatMs);
270
+ }
271
+
272
+ function sendSocketResponse(socket, requestId, payload, meta) {
273
+ if (socket !== activeSocket || socket.readyState !== WebSocket.OPEN) return;
274
+ try { socket.send(serializeResponse(requestId, payload, meta)); } catch {}
275
+ }
276
+
277
+ function scheduleReconnect(code, reason) {
278
+ if (stopping || reauthorizationRequired) return;
279
+ connectionState.markDisconnected(code, reason);
280
+ const delay = connectionState.nextDelay();
281
+ connectionState.scheduleReconnect(delay);
282
+ clearReconnectTimer();
283
+ console.log(`Reconnect scheduled in ${delay}ms`);
284
+ reconnectTimer = setTimeout(() => {
285
+ reconnectTimer = null;
286
+ connect();
287
+ }, delay);
288
+ }
289
+
290
+
291
+ function requireProtocolUpdate(details = {}) {
292
+ if (stopping) return;
293
+ stopping = true;
294
+ connectionState.markStopping("protocol_incompatible");
295
+ clearReconnectTimer();
296
+ stopHeartbeat();
297
+ const socket = activeSocket;
298
+ activeSocket = null;
299
+ try { socket?.terminate(); } catch {}
300
+ desktop.close();
301
+ const expected = details.expectedProtocolVersion ?? "current";
302
+ const received = details.receivedProtocolVersion ?? agentHello.protocolVersion;
303
+ console.error(`Agent protocol is incompatible (local=${received}, relay=${expected}). Update Chat Relay, then reconnect.`);
304
+ setImmediate(() => process.exit(3));
305
+ }
306
+
307
+ function requireReauthorization(reason = "credential_revoked") {
146
308
  if (reauthorizationRequired) return;
147
309
  reauthorizationRequired = true;
310
+ connectionState.markReauthorization(reason);
311
+ clearReconnectTimer();
312
+ stopHeartbeat();
313
+ const socket = activeSocket;
314
+ activeSocket = null;
315
+ try { socket?.terminate(); } catch {}
148
316
  desktop.close();
149
- process.exitCode = 2;
150
317
  console.error("Agent credential was revoked or rejected.");
151
318
  console.error('Recovery: run "chat-relay login --force", then "chat-relay remote".');
319
+ setImmediate(() => process.exit(2));
152
320
  }
153
321
 
154
322
  function connect() {
155
- if (reauthorizationRequired) return;
323
+ if (stopping || reauthorizationRequired) return;
324
+ clearReconnectTimer();
325
+ connectionState.markConnecting();
156
326
  console.log(`Connecting to ${wsUrl}`);
157
327
  console.log(`Terminal access: ${terminalEnabled ? "enabled" : "disabled"}`);
158
- console.log("Desktop access: " + (desktopEnabled ? "enabled" : "disabled"));
328
+ const desktopConfig = desktop.getConfig();
329
+ console.log("Desktop access: " + (!desktopEnabled ? "disabled" : desktopConfig.supported ? "enabled" : `unsupported (${desktopConfig.platform})`));
330
+
159
331
  const socket = new WebSocket(wsUrl, {
160
332
  headers: { Authorization: `Bearer ${agentToken}` },
161
333
  });
334
+ activeSocket = socket;
162
335
 
163
- socket.on("open", () => console.log("Agent connected"));
336
+ socket.on("open", () => {
337
+ if (socket !== activeSocket) return;
338
+ socket.send(JSON.stringify(agentHello));
339
+ connectionState.markConnected();
340
+ startHeartbeat(socket);
341
+ console.log("Agent connected");
342
+ });
164
343
 
165
344
  socket.on("message", async (raw) => {
345
+ if (socket !== activeSocket) return;
166
346
  let message;
167
347
  try {
168
348
  message = JSON.parse(raw.toString());
@@ -171,8 +351,11 @@ function connect() {
171
351
  }
172
352
 
173
353
  if (message?.control === "credential_revoked") {
174
- requireReauthorization();
175
- try { socket.close(4001, "credential_revoked"); } catch {}
354
+ requireReauthorization("credential_revoked");
355
+ return;
356
+ }
357
+ if (message?.control === "protocol_incompatible") {
358
+ requireProtocolUpdate(message);
176
359
  return;
177
360
  }
178
361
 
@@ -182,61 +365,111 @@ function connect() {
182
365
  const action = message.payload && typeof message.payload === "object"
183
366
  ? String(message.payload.action ?? "unknown")
184
367
  : "unknown";
368
+ let scheduleMeta = { lane: "unknown", queueWaitMs: 0, queueDepthAtStart: 0, activeAtStart: 0 };
369
+ let handlerDurationMs = 0;
185
370
  try {
186
- const result = await handlePayload(message.payload);
371
+ const result = await scheduler.run(action, async (meta = {}) => {
372
+ scheduleMeta = { ...scheduleMeta, ...meta };
373
+ const handlerStartedAt = Date.now();
374
+ try {
375
+ return await handlePayload(message.payload);
376
+ } finally {
377
+ handlerDurationMs = Math.max(0, Date.now() - handlerStartedAt);
378
+ }
379
+ });
187
380
  recentCalls.push({
188
381
  at: new Date().toISOString(),
189
382
  action,
190
383
  durationMs: Date.now() - startedAt,
384
+ queueWaitMs: scheduleMeta.queueWaitMs,
385
+ handlerDurationMs,
386
+ lane: scheduleMeta.lane,
191
387
  ok: !(result && typeof result === "object" && result.ok === false),
192
388
  });
193
389
  if (recentCalls.length > 100) recentCalls.shift();
194
- socket.send(serializeResponse(message.requestId, result));
390
+ sendSocketResponse(socket, message.requestId, result, {
391
+ agentQueueWaitMs: scheduleMeta.queueWaitMs,
392
+ agentHandlerMs: handlerDurationMs,
393
+ lane: scheduleMeta.lane,
394
+ });
195
395
  } catch (error) {
196
396
  recentCalls.push({
197
397
  at: new Date().toISOString(),
198
398
  action,
199
399
  durationMs: Date.now() - startedAt,
400
+ queueWaitMs: scheduleMeta.queueWaitMs,
401
+ handlerDurationMs,
402
+ lane: scheduleMeta.lane,
200
403
  ok: false,
201
404
  error: error instanceof Error ? error.message : "agent_error",
202
405
  });
203
406
  if (recentCalls.length > 100) recentCalls.shift();
204
- socket.send(serializeResponse(message.requestId, {
407
+ sendSocketResponse(socket, message.requestId, {
205
408
  ok: false,
206
409
  error: error instanceof Error ? error.message : "agent_error",
207
- }));
410
+ });
208
411
  }
209
412
  });
210
413
 
211
414
  socket.on("unexpected-response", (_request, response) => {
212
415
  if (response.statusCode === 401 || response.statusCode === 403) {
213
416
  response.resume();
214
- requireReauthorization();
215
- socket.terminate();
417
+ requireReauthorization("credential_rejected");
216
418
  }
217
419
  });
218
420
 
219
421
  socket.on("close", (code, reason) => {
422
+ if (socket !== activeSocket) return;
423
+ activeSocket = null;
424
+ stopHeartbeat();
220
425
  const reasonText = reason.toString();
221
426
  console.log("Agent disconnected (" + code + ") " + reasonText);
222
427
  if (code === 4001 || reasonText === "credential_revoked") {
223
- requireReauthorization();
428
+ requireReauthorization("credential_revoked");
429
+ return;
430
+ }
431
+ if (code === 4002 || reasonText === "protocol_incompatible") {
432
+ requireProtocolUpdate({ receivedProtocolVersion: agentHello.protocolVersion });
224
433
  return;
225
434
  }
226
- if (!reauthorizationRequired) setTimeout(connect, reconnectMs);
435
+ scheduleReconnect(code, reasonText || "socket_closed");
227
436
  });
228
437
 
229
438
  socket.on("error", (error) => {
230
- if (!reauthorizationRequired) console.error("WebSocket error:", error.message);
439
+ if (socket === activeSocket && !stopping && !reauthorizationRequired) {
440
+ console.error("WebSocket error:", error.message);
441
+ }
231
442
  });
232
443
  }
233
444
 
234
- function shutdown() {
445
+ function restartAgentProcess() {
446
+ if (stopping) return;
447
+ stopping = true;
448
+ connectionState.markStopping("restart_requested");
449
+ clearReconnectTimer();
450
+ stopHeartbeat();
451
+ const socket = activeSocket;
452
+ activeSocket = null;
453
+ try { socket?.close(1012, "restart_requested"); } catch {}
454
+ desktop.close();
455
+ setTimeout(() => process.exit(AGENT_RESTART_EXIT_CODE), 50);
456
+ }
457
+
458
+ function shutdown(reason) {
459
+ if (stopping) return;
460
+ stopping = true;
461
+ connectionState.markStopping(reason);
462
+ clearReconnectTimer();
463
+ stopHeartbeat();
464
+ const socket = activeSocket;
465
+ activeSocket = null;
466
+ try { socket?.close(1000, "client_shutdown"); } catch {}
235
467
  desktop.close();
468
+ setTimeout(() => process.exit(0), 50);
236
469
  }
237
470
 
238
- process.once("SIGINT", shutdown);
239
- process.once("SIGTERM", shutdown);
240
- process.once("exit", shutdown);
471
+ process.once("SIGINT", () => shutdown("SIGINT"));
472
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
473
+ process.once("exit", () => desktop.close());
241
474
 
242
475
  connect();
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+
3
+ export const AGENT_PROTOCOL_VERSION = 1;
4
+
5
+ let cachedVersion = null;
6
+
7
+ export function getAgentVersion() {
8
+ if (cachedVersion) return cachedVersion;
9
+ try {
10
+ const packageUrl = new URL("../package.json", import.meta.url);
11
+ const parsed = JSON.parse(fs.readFileSync(packageUrl, "utf8"));
12
+ cachedVersion = typeof parsed.version === "string" && parsed.version ? parsed.version : "0.0.0";
13
+ } catch {
14
+ cachedVersion = "0.0.0";
15
+ }
16
+ return cachedVersion;
17
+ }
18
+
19
+ export function getAgentCapabilities(options = {}) {
20
+ const terminalEnabled = options.terminalEnabled === true;
21
+ const desktopEnabled = options.desktopEnabled === true;
22
+ const platform = String(options.platform || process.platform);
23
+
24
+ const capabilities = [
25
+ "transport.heartbeat",
26
+ "filesystem.stat",
27
+ "filesystem.list",
28
+ "filesystem.read",
29
+ "filesystem.readMany",
30
+ "filesystem.batch",
31
+ "filesystem.artifact",
32
+ "filesystem.write",
33
+ "filesystem.edit",
34
+ "filesystem.mkdir",
35
+ "filesystem.move",
36
+ "filesystem.delete",
37
+ "filesystem.search",
38
+ "process.list",
39
+ "process.kill",
40
+ ];
41
+
42
+ if (terminalEnabled) {
43
+ capabilities.push("terminal.exec", "terminal.sessions", "terminal.batch");
44
+ }
45
+
46
+ if (desktopEnabled && platform === "win32") {
47
+ capabilities.push("desktop.screenshot", "desktop.control", "desktop.step");
48
+ }
49
+
50
+ return capabilities;
51
+ }
52
+
53
+ export function buildAgentHello(options = {}) {
54
+ const platform = String(options.platform || process.platform);
55
+ return {
56
+ control: "agent_hello",
57
+ protocolVersion: Number(options.protocolVersion ?? AGENT_PROTOCOL_VERSION),
58
+ agentVersion: String(options.agentVersion || getAgentVersion()),
59
+ platform,
60
+ arch: String(options.arch || process.arch),
61
+ capabilities: getAgentCapabilities({
62
+ terminalEnabled: options.terminalEnabled === true,
63
+ desktopEnabled: options.desktopEnabled === true,
64
+ platform,
65
+ }),
66
+ };
67
+ }