@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/local-agent.mjs
CHANGED
|
@@ -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({
|
|
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 (
|
|
99
|
+
switch (action) {
|
|
50
100
|
case "ping":
|
|
51
101
|
return { ok: true, action: "pong", at: new Date().toISOString(), agentId, agentName };
|
|
52
102
|
|
|
@@ -56,21 +106,50 @@ 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);
|
|
68
143
|
case "fs.list":
|
|
69
|
-
return files.list(payload.path, payload.depth);
|
|
144
|
+
return files.list(payload.path, payload.depth, payload.offset, payload.limit, payload.maxBytes);
|
|
70
145
|
case "fs.read":
|
|
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,17 +172,27 @@ 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":
|
|
99
186
|
return desktop.keyboardInput(payload);
|
|
187
|
+
case "desktop.step":
|
|
188
|
+
return desktop.step(payload);
|
|
100
189
|
|
|
101
190
|
case "terminal.exec":
|
|
102
191
|
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
103
192
|
return terminals.exec(payload.command, payload.cwd, payload.timeoutMs);
|
|
104
193
|
case "terminal.batch.start":
|
|
105
194
|
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
106
|
-
return terminals.batchStart(payload.jobs, { cwd: payload.cwd, timeoutMs: payload.timeoutMs, concurrency: payload.concurrency });
|
|
195
|
+
return terminals.batchStart(payload.jobs, { cwd: payload.cwd, timeoutMs: payload.timeoutMs, concurrency: payload.concurrency, observability: payload.observability });
|
|
107
196
|
case "terminal.batch.status":
|
|
108
197
|
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
109
198
|
return terminals.batchStatus(payload.batchId);
|
|
@@ -115,10 +204,10 @@ async function handlePayload(payload) {
|
|
|
115
204
|
return terminals.batchCancel(payload.batchId);
|
|
116
205
|
case "terminal.start":
|
|
117
206
|
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
118
|
-
return terminals.start(payload.command, payload.cwd);
|
|
207
|
+
return terminals.start(payload.command, payload.cwd, payload.observability);
|
|
119
208
|
case "terminal.shell.start":
|
|
120
209
|
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
121
|
-
return terminals.startShell(payload.cwd);
|
|
210
|
+
return terminals.startShell(payload.cwd, payload.observability);
|
|
122
211
|
case "terminal.read":
|
|
123
212
|
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
124
213
|
return terminals.read(payload.sessionId, payload.afterSeq, payload.maxChars);
|
|
@@ -131,32 +220,129 @@ async function handlePayload(payload) {
|
|
|
131
220
|
case "terminal.list":
|
|
132
221
|
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
133
222
|
return terminals.list();
|
|
223
|
+
case "terminal.observability":
|
|
224
|
+
if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
|
|
225
|
+
return terminals.observability();
|
|
134
226
|
|
|
135
227
|
default:
|
|
136
228
|
return { ok: false, error: "unknown_action" };
|
|
137
229
|
}
|
|
138
230
|
}
|
|
139
231
|
|
|
140
|
-
function
|
|
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") {
|
|
141
308
|
if (reauthorizationRequired) return;
|
|
142
309
|
reauthorizationRequired = true;
|
|
143
|
-
|
|
310
|
+
connectionState.markReauthorization(reason);
|
|
311
|
+
clearReconnectTimer();
|
|
312
|
+
stopHeartbeat();
|
|
313
|
+
const socket = activeSocket;
|
|
314
|
+
activeSocket = null;
|
|
315
|
+
try { socket?.terminate(); } catch {}
|
|
316
|
+
desktop.close();
|
|
144
317
|
console.error("Agent credential was revoked or rejected.");
|
|
145
318
|
console.error('Recovery: run "chat-relay login --force", then "chat-relay remote".');
|
|
319
|
+
setImmediate(() => process.exit(2));
|
|
146
320
|
}
|
|
147
321
|
|
|
148
322
|
function connect() {
|
|
149
|
-
if (reauthorizationRequired) return;
|
|
323
|
+
if (stopping || reauthorizationRequired) return;
|
|
324
|
+
clearReconnectTimer();
|
|
325
|
+
connectionState.markConnecting();
|
|
150
326
|
console.log(`Connecting to ${wsUrl}`);
|
|
151
327
|
console.log(`Terminal access: ${terminalEnabled ? "enabled" : "disabled"}`);
|
|
152
|
-
|
|
328
|
+
const desktopConfig = desktop.getConfig();
|
|
329
|
+
console.log("Desktop access: " + (!desktopEnabled ? "disabled" : desktopConfig.supported ? "enabled" : `unsupported (${desktopConfig.platform})`));
|
|
330
|
+
|
|
153
331
|
const socket = new WebSocket(wsUrl, {
|
|
154
332
|
headers: { Authorization: `Bearer ${agentToken}` },
|
|
155
333
|
});
|
|
334
|
+
activeSocket = socket;
|
|
156
335
|
|
|
157
|
-
socket.on("open", () =>
|
|
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
|
+
});
|
|
158
343
|
|
|
159
344
|
socket.on("message", async (raw) => {
|
|
345
|
+
if (socket !== activeSocket) return;
|
|
160
346
|
let message;
|
|
161
347
|
try {
|
|
162
348
|
message = JSON.parse(raw.toString());
|
|
@@ -165,8 +351,11 @@ function connect() {
|
|
|
165
351
|
}
|
|
166
352
|
|
|
167
353
|
if (message?.control === "credential_revoked") {
|
|
168
|
-
requireReauthorization();
|
|
169
|
-
|
|
354
|
+
requireReauthorization("credential_revoked");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (message?.control === "protocol_incompatible") {
|
|
358
|
+
requireProtocolUpdate(message);
|
|
170
359
|
return;
|
|
171
360
|
}
|
|
172
361
|
|
|
@@ -176,53 +365,111 @@ function connect() {
|
|
|
176
365
|
const action = message.payload && typeof message.payload === "object"
|
|
177
366
|
? String(message.payload.action ?? "unknown")
|
|
178
367
|
: "unknown";
|
|
368
|
+
let scheduleMeta = { lane: "unknown", queueWaitMs: 0, queueDepthAtStart: 0, activeAtStart: 0 };
|
|
369
|
+
let handlerDurationMs = 0;
|
|
179
370
|
try {
|
|
180
|
-
const result = await
|
|
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
|
+
});
|
|
181
380
|
recentCalls.push({
|
|
182
381
|
at: new Date().toISOString(),
|
|
183
382
|
action,
|
|
184
383
|
durationMs: Date.now() - startedAt,
|
|
384
|
+
queueWaitMs: scheduleMeta.queueWaitMs,
|
|
385
|
+
handlerDurationMs,
|
|
386
|
+
lane: scheduleMeta.lane,
|
|
185
387
|
ok: !(result && typeof result === "object" && result.ok === false),
|
|
186
388
|
});
|
|
187
389
|
if (recentCalls.length > 100) recentCalls.shift();
|
|
188
|
-
socket
|
|
390
|
+
sendSocketResponse(socket, message.requestId, result, {
|
|
391
|
+
agentQueueWaitMs: scheduleMeta.queueWaitMs,
|
|
392
|
+
agentHandlerMs: handlerDurationMs,
|
|
393
|
+
lane: scheduleMeta.lane,
|
|
394
|
+
});
|
|
189
395
|
} catch (error) {
|
|
190
396
|
recentCalls.push({
|
|
191
397
|
at: new Date().toISOString(),
|
|
192
398
|
action,
|
|
193
399
|
durationMs: Date.now() - startedAt,
|
|
400
|
+
queueWaitMs: scheduleMeta.queueWaitMs,
|
|
401
|
+
handlerDurationMs,
|
|
402
|
+
lane: scheduleMeta.lane,
|
|
194
403
|
ok: false,
|
|
195
404
|
error: error instanceof Error ? error.message : "agent_error",
|
|
196
405
|
});
|
|
197
406
|
if (recentCalls.length > 100) recentCalls.shift();
|
|
198
|
-
socket
|
|
407
|
+
sendSocketResponse(socket, message.requestId, {
|
|
199
408
|
ok: false,
|
|
200
409
|
error: error instanceof Error ? error.message : "agent_error",
|
|
201
|
-
})
|
|
410
|
+
});
|
|
202
411
|
}
|
|
203
412
|
});
|
|
204
413
|
|
|
205
414
|
socket.on("unexpected-response", (_request, response) => {
|
|
206
415
|
if (response.statusCode === 401 || response.statusCode === 403) {
|
|
207
416
|
response.resume();
|
|
208
|
-
requireReauthorization();
|
|
209
|
-
socket.terminate();
|
|
417
|
+
requireReauthorization("credential_rejected");
|
|
210
418
|
}
|
|
211
419
|
});
|
|
212
420
|
|
|
213
421
|
socket.on("close", (code, reason) => {
|
|
422
|
+
if (socket !== activeSocket) return;
|
|
423
|
+
activeSocket = null;
|
|
424
|
+
stopHeartbeat();
|
|
214
425
|
const reasonText = reason.toString();
|
|
215
426
|
console.log("Agent disconnected (" + code + ") " + reasonText);
|
|
216
427
|
if (code === 4001 || reasonText === "credential_revoked") {
|
|
217
|
-
requireReauthorization();
|
|
428
|
+
requireReauthorization("credential_revoked");
|
|
218
429
|
return;
|
|
219
430
|
}
|
|
220
|
-
if (
|
|
431
|
+
if (code === 4002 || reasonText === "protocol_incompatible") {
|
|
432
|
+
requireProtocolUpdate({ receivedProtocolVersion: agentHello.protocolVersion });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
scheduleReconnect(code, reasonText || "socket_closed");
|
|
221
436
|
});
|
|
222
437
|
|
|
223
438
|
socket.on("error", (error) => {
|
|
224
|
-
if (!reauthorizationRequired)
|
|
439
|
+
if (socket === activeSocket && !stopping && !reauthorizationRequired) {
|
|
440
|
+
console.error("WebSocket error:", error.message);
|
|
441
|
+
}
|
|
225
442
|
});
|
|
226
443
|
}
|
|
227
444
|
|
|
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 {}
|
|
467
|
+
desktop.close();
|
|
468
|
+
setTimeout(() => process.exit(0), 50);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
472
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
473
|
+
process.once("exit", () => desktop.close());
|
|
474
|
+
|
|
228
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
|
+
}
|
|
@@ -119,22 +119,24 @@ export class TerminalManager {
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
start(command, cwd) {
|
|
122
|
+
start(command, cwd, observability) {
|
|
123
123
|
validateCommand(command);
|
|
124
124
|
return this.#spawnSession({
|
|
125
125
|
type: "command",
|
|
126
126
|
command,
|
|
127
127
|
cwd: safeCwd(cwd),
|
|
128
128
|
args: ["-NoLogo", "-NoProfile", "-Command", command],
|
|
129
|
+
observability,
|
|
129
130
|
});
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
startShell(cwd) {
|
|
133
|
+
startShell(cwd, observability) {
|
|
133
134
|
return this.#spawnSession({
|
|
134
135
|
type: "shell",
|
|
135
136
|
command: "powershell",
|
|
136
137
|
cwd: safeCwd(cwd),
|
|
137
138
|
args: ["-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
|
|
139
|
+
observability,
|
|
138
140
|
});
|
|
139
141
|
}
|
|
140
142
|
|
|
@@ -143,6 +145,45 @@ export class TerminalManager {
|
|
|
143
145
|
return [...this.sessions.values()].map((session) => this.#summary(session));
|
|
144
146
|
}
|
|
145
147
|
|
|
148
|
+
observability() {
|
|
149
|
+
this.#cleanup();
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
sessions: [...this.sessions.values()].map((session) => ({
|
|
153
|
+
sessionId: session.id,
|
|
154
|
+
type: session.type,
|
|
155
|
+
status: session.status,
|
|
156
|
+
exitCode: session.exitCode,
|
|
157
|
+
startedAt: session.startedAt,
|
|
158
|
+
endedAt: session.endedAt,
|
|
159
|
+
...(session.observability?.userId ? { userId: session.observability.userId } : {}),
|
|
160
|
+
...(session.observability?.activityId ? { activityId: session.observability.activityId } : {}),
|
|
161
|
+
...(session.observability?.toolCallId ? { toolCallId: session.observability.toolCallId } : {}),
|
|
162
|
+
})),
|
|
163
|
+
batches: [...this.batches.values()].map((batch) => ({
|
|
164
|
+
batchId: batch.batchId,
|
|
165
|
+
createdAt: batch.createdAt,
|
|
166
|
+
endedAt: batch.endedAt,
|
|
167
|
+
cancelled: batch.cancelled,
|
|
168
|
+
...(batch.observability?.userId ? { userId: batch.observability.userId } : {}),
|
|
169
|
+
...(batch.observability?.activityId ? { activityId: batch.observability.activityId } : {}),
|
|
170
|
+
...(batch.observability?.toolCallId ? { toolCallId: batch.observability.toolCallId } : {}),
|
|
171
|
+
counts: this.#batchSummary(batch).counts,
|
|
172
|
+
jobs: batch.jobs.map((job) => ({
|
|
173
|
+
jobId: job.jobId,
|
|
174
|
+
status: job.status,
|
|
175
|
+
queuedAt: job.queuedAt,
|
|
176
|
+
startedAt: job.startedAt,
|
|
177
|
+
endedAt: job.endedAt,
|
|
178
|
+
durationMs: job.durationMs,
|
|
179
|
+
exitCode: job.exitCode,
|
|
180
|
+
})),
|
|
181
|
+
})),
|
|
182
|
+
activeExecJobs: this.activeBatchJobs,
|
|
183
|
+
queuedJobs: this.batchQueue.length,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
146
187
|
read(sessionId, afterSeq = 0, maxChars = MAX_READ_CHARS) {
|
|
147
188
|
this.#cleanup();
|
|
148
189
|
const session = this.#get(sessionId);
|
|
@@ -216,6 +257,7 @@ export class TerminalManager {
|
|
|
216
257
|
endedAt: null,
|
|
217
258
|
requestedConcurrency,
|
|
218
259
|
cancelled: false,
|
|
260
|
+
observability: options.observability ?? null,
|
|
219
261
|
jobs: jobs.map((input, index) => {
|
|
220
262
|
const item = typeof input === "string" ? { command: input } : input;
|
|
221
263
|
validateCommand(item?.command);
|
|
@@ -311,7 +353,7 @@ export class TerminalManager {
|
|
|
311
353
|
};
|
|
312
354
|
}
|
|
313
355
|
|
|
314
|
-
#spawnSession({ type, command, cwd, args }) {
|
|
356
|
+
#spawnSession({ type, command, cwd, args, observability }) {
|
|
315
357
|
this.#cleanup();
|
|
316
358
|
const running = [...this.sessions.values()].filter((session) => session.status === "running").length;
|
|
317
359
|
if (running >= MAX_SESSIONS) throw new Error("too_many_sessions");
|
|
@@ -337,6 +379,7 @@ export class TerminalManager {
|
|
|
337
379
|
chunks: [],
|
|
338
380
|
nextSeq: 1,
|
|
339
381
|
totalChars: 0,
|
|
382
|
+
observability: observability ?? null,
|
|
340
383
|
};
|
|
341
384
|
|
|
342
385
|
this.sessions.set(session.id, session);
|