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