@anusornneal/chat-relay 0.9.1 → 0.10.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 +1 -1
- package/agent/connection-state.mjs +2 -1
- package/agent/heartbeat-ack-watchdog.mjs +89 -0
- package/agent/local-agent.mjs +86 -7
- package/agent/terminal-continuity.mjs +49 -0
- package/agent/terminal-manager.mjs +20 -4
- package/cli/index.mjs +1 -2
- package/cli/remote.mjs +59 -46
- package/cli/runner-ownership.mjs +209 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ From any directory:
|
|
|
35
35
|
npx @anusornneal/chat-relay@latest remote
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
On the first run, Chat Relay opens browser-based device authorization.
|
|
38
|
+
On the first run, Chat Relay opens browser-based device authorization. Choose **Continue with Google and authorize computer**, sign in with the same Google account used for the ChatGPT connector, then return to the terminal. Device sign-in requires configured Google OAuth and does not accept a local username/password.
|
|
39
39
|
|
|
40
40
|
The CLI saves the device credentials in your user profile, so later you can reconnect with the same command:
|
|
41
41
|
|
|
@@ -73,7 +73,8 @@ export class AgentConnectionState {
|
|
|
73
73
|
const connectedAtMs = this.connectedAt ? Date.parse(this.connectedAt) : NaN;
|
|
74
74
|
this.disconnectedAt = disconnectedAt;
|
|
75
75
|
this.lastDisconnectedAt = disconnectedAt;
|
|
76
|
-
|
|
76
|
+
const closeCode = code === null || code === undefined ? NaN : Number(code);
|
|
77
|
+
this.lastCloseCode = Number.isFinite(closeCode) ? closeCode : null;
|
|
77
78
|
this.lastDisconnectReason = cleanReason(reason);
|
|
78
79
|
this.lastConnectionDurationMs = Number.isFinite(connectedAtMs)
|
|
79
80
|
? Math.max(0, Math.trunc(Number(now) - connectedAtMs))
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const HEARTBEAT_ACK_TIMEOUT_REASON = "heartbeat_ack_timeout";
|
|
2
|
+
|
|
3
|
+
export function heartbeatAckDecision({ now, deadlineAt }) {
|
|
4
|
+
const currentTime = Number(now);
|
|
5
|
+
const deadline = Number(deadlineAt);
|
|
6
|
+
if (!Number.isFinite(currentTime) || !Number.isFinite(deadline)) {
|
|
7
|
+
return { timedOut: false, remainingMs: null, overdueMs: 0 };
|
|
8
|
+
}
|
|
9
|
+
if (currentTime <= deadline) {
|
|
10
|
+
return { timedOut: false, remainingMs: deadline - currentTime, overdueMs: 0 };
|
|
11
|
+
}
|
|
12
|
+
return { timedOut: true, remainingMs: 0, overdueMs: currentTime - deadline };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class HeartbeatAckWatchdog {
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.timeoutMs = Number(options.timeoutMs);
|
|
18
|
+
this.onTimeout = options.onTimeout;
|
|
19
|
+
this.now = options.now ?? Date.now;
|
|
20
|
+
this.setTimer = options.setTimer ?? setTimeout;
|
|
21
|
+
this.clearTimer = options.clearTimer ?? clearTimeout;
|
|
22
|
+
this.timer = null;
|
|
23
|
+
this.active = false;
|
|
24
|
+
this.lastAckAt = null;
|
|
25
|
+
this.deadlineAt = null;
|
|
26
|
+
this.lastTimeout = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
start(now = this.now()) {
|
|
30
|
+
this.active = true;
|
|
31
|
+
this.lastAckAt = Number(now);
|
|
32
|
+
this.#arm();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
acknowledge(now = this.now()) {
|
|
36
|
+
if (!this.active) return;
|
|
37
|
+
this.lastAckAt = Number(now);
|
|
38
|
+
this.#arm();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stop() {
|
|
42
|
+
this.active = false;
|
|
43
|
+
this.deadlineAt = null;
|
|
44
|
+
if (this.timer !== null) this.clearTimer(this.timer);
|
|
45
|
+
this.timer = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
snapshot(now = this.now()) {
|
|
49
|
+
const decision = heartbeatAckDecision({ now, deadlineAt: this.deadlineAt });
|
|
50
|
+
return {
|
|
51
|
+
active: this.active,
|
|
52
|
+
timeoutMs: this.timeoutMs,
|
|
53
|
+
lastAckAt: this.lastAckAt === null ? null : new Date(this.lastAckAt).toISOString(),
|
|
54
|
+
deadlineAt: this.deadlineAt === null ? null : new Date(this.deadlineAt).toISOString(),
|
|
55
|
+
remainingMs: this.active ? decision.remainingMs : null,
|
|
56
|
+
lastTimeoutReason: this.lastTimeout?.reason ?? null,
|
|
57
|
+
lastTimedOutAt: this.lastTimeout ? new Date(this.lastTimeout.detectedAt).toISOString() : null,
|
|
58
|
+
lastTimeoutOverdueMs: this.lastTimeout?.overdueMs ?? null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#arm() {
|
|
63
|
+
if (this.timer !== null) this.clearTimer(this.timer);
|
|
64
|
+
this.deadlineAt = this.lastAckAt + this.timeoutMs;
|
|
65
|
+
const delay = Math.max(1, this.deadlineAt - this.now() + 1);
|
|
66
|
+
this.timer = this.setTimer(() => this.#check(), delay);
|
|
67
|
+
this.timer?.unref?.();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#check() {
|
|
71
|
+
this.timer = null;
|
|
72
|
+
if (!this.active) return;
|
|
73
|
+
const detectedAt = Number(this.now());
|
|
74
|
+
const decision = heartbeatAckDecision({ now: detectedAt, deadlineAt: this.deadlineAt });
|
|
75
|
+
if (!decision.timedOut) {
|
|
76
|
+
this.#arm();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.active = false;
|
|
80
|
+
this.lastTimeout = {
|
|
81
|
+
reason: HEARTBEAT_ACK_TIMEOUT_REASON,
|
|
82
|
+
lastAckAt: this.lastAckAt,
|
|
83
|
+
deadlineAt: this.deadlineAt,
|
|
84
|
+
detectedAt,
|
|
85
|
+
overdueMs: decision.overdueMs,
|
|
86
|
+
};
|
|
87
|
+
this.onTimeout({ ...this.lastTimeout, timeoutMs: this.timeoutMs });
|
|
88
|
+
}
|
|
89
|
+
}
|
package/agent/local-agent.mjs
CHANGED
|
@@ -7,6 +7,10 @@ import { CapabilityScheduler } from "./capability-scheduler.mjs";
|
|
|
7
7
|
import { AgentConnectionState } from "./connection-state.mjs";
|
|
8
8
|
import { buildAgentHello } from "./protocol.mjs";
|
|
9
9
|
import { AgentLifecycle, AGENT_RESTART_EXIT_CODE } from "./lifecycle.mjs";
|
|
10
|
+
import {
|
|
11
|
+
HeartbeatAckWatchdog,
|
|
12
|
+
HEARTBEAT_ACK_TIMEOUT_REASON,
|
|
13
|
+
} from "./heartbeat-ack-watchdog.mjs";
|
|
10
14
|
|
|
11
15
|
const relayUrl = process.env.RELAY_URL;
|
|
12
16
|
const agentToken = process.env.AGENT_TOKEN;
|
|
@@ -66,8 +70,25 @@ let reauthorizationRequired = false;
|
|
|
66
70
|
let activeSocket = null;
|
|
67
71
|
let reconnectTimer = null;
|
|
68
72
|
let heartbeatTimer = null;
|
|
73
|
+
let transportHeartbeatTimer = null;
|
|
74
|
+
let transportPongDeadlineTimer = null;
|
|
69
75
|
let stopping = false;
|
|
70
76
|
|
|
77
|
+
const TRANSPORT_PING_MS = Math.max(5000, Math.min(Number(process.env.AGENT_TRANSPORT_PING_MS) || 20000, 60000));
|
|
78
|
+
const TRANSPORT_PONG_TIMEOUT_MS = Math.max(3000, Math.min(Number(process.env.AGENT_TRANSPORT_PONG_TIMEOUT_MS) || 8000, 30000));
|
|
79
|
+
const FAST_RECONNECT_MS = Math.max(100, Math.min(Number(process.env.AGENT_FAST_RECONNECT_MS) || 250, 5000));
|
|
80
|
+
const FAST_RECONNECT_STABLE_MS = Math.max(5000, Math.min(Number(process.env.AGENT_FAST_RECONNECT_STABLE_MS) || 30000, 300000));
|
|
81
|
+
const HEARTBEAT_ACK_TIMEOUT_MS = Math.max(15000, Math.min(Number(process.env.AGENT_HEARTBEAT_ACK_TIMEOUT_MS) || 45000, 180000));
|
|
82
|
+
const heartbeatAckWatchdog = new HeartbeatAckWatchdog({
|
|
83
|
+
timeoutMs: HEARTBEAT_ACK_TIMEOUT_MS,
|
|
84
|
+
onTimeout: () => {
|
|
85
|
+
const socket = activeSocket;
|
|
86
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
87
|
+
connectionState.markSocketError(HEARTBEAT_ACK_TIMEOUT_REASON);
|
|
88
|
+
try { socket.terminate(); } catch {}
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
71
92
|
function currentWorkSummary() {
|
|
72
93
|
const terminal = terminals.observability();
|
|
73
94
|
const terminalExec = scheduler.snapshot().terminalExec || {};
|
|
@@ -125,10 +146,11 @@ async function handlePayload(payload) {
|
|
|
125
146
|
allowedRoots: files.getRoots(),
|
|
126
147
|
reconnectMs: connectionState.baseReconnectMs,
|
|
127
148
|
connection: { ...connectionState.snapshot(), processId: process.pid },
|
|
149
|
+
heartbeatAckWatchdog: heartbeatAckWatchdog.snapshot(),
|
|
128
150
|
lifecycle: lifecycle.snapshot(),
|
|
129
151
|
terminalContinuity: {
|
|
130
152
|
processId: process.pid,
|
|
131
|
-
|
|
153
|
+
...terminals.getContinuityConfig(),
|
|
132
154
|
},
|
|
133
155
|
protocol: {
|
|
134
156
|
protocolVersion: agentHello.protocolVersion,
|
|
@@ -250,9 +272,39 @@ function clearReconnectTimer() {
|
|
|
250
272
|
}
|
|
251
273
|
|
|
252
274
|
function stopHeartbeat() {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
275
|
+
heartbeatAckWatchdog.stop();
|
|
276
|
+
if (heartbeatTimer) {
|
|
277
|
+
clearInterval(heartbeatTimer);
|
|
278
|
+
heartbeatTimer = null;
|
|
279
|
+
}
|
|
280
|
+
if (transportHeartbeatTimer) {
|
|
281
|
+
clearInterval(transportHeartbeatTimer);
|
|
282
|
+
transportHeartbeatTimer = null;
|
|
283
|
+
}
|
|
284
|
+
if (transportPongDeadlineTimer) {
|
|
285
|
+
clearTimeout(transportPongDeadlineTimer);
|
|
286
|
+
transportPongDeadlineTimer = null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function armTransportPongDeadline(socket) {
|
|
291
|
+
if (transportPongDeadlineTimer) clearTimeout(transportPongDeadlineTimer);
|
|
292
|
+
transportPongDeadlineTimer = setTimeout(() => {
|
|
293
|
+
if (socket !== activeSocket || socket.readyState !== WebSocket.OPEN) return;
|
|
294
|
+
connectionState.markSocketError("transport_pong_timeout");
|
|
295
|
+
try { socket.terminate(); } catch {}
|
|
296
|
+
}, TRANSPORT_PONG_TIMEOUT_MS);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sendTransportPing(socket) {
|
|
300
|
+
if (socket !== activeSocket || socket.readyState !== WebSocket.OPEN) return;
|
|
301
|
+
try {
|
|
302
|
+
socket.ping();
|
|
303
|
+
armTransportPongDeadline(socket);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
connectionState.markSocketError(error instanceof Error ? error.message : "transport_ping_failed");
|
|
306
|
+
try { socket.terminate(); } catch {}
|
|
307
|
+
}
|
|
256
308
|
}
|
|
257
309
|
|
|
258
310
|
function sendHeartbeat(socket) {
|
|
@@ -276,6 +328,7 @@ function sendHeartbeat(socket) {
|
|
|
276
328
|
lastDisconnectReason: connectionState.lastDisconnectReason,
|
|
277
329
|
lastConnectionDurationMs: connectionState.lastConnectionDurationMs,
|
|
278
330
|
lastSocketError: connectionState.lastSocketError,
|
|
331
|
+
heartbeatAckWatchdog: heartbeatAckWatchdog.snapshot(now),
|
|
279
332
|
queues: scheduler.snapshot(),
|
|
280
333
|
},
|
|
281
334
|
}));
|
|
@@ -285,8 +338,10 @@ function sendHeartbeat(socket) {
|
|
|
285
338
|
|
|
286
339
|
function startHeartbeat(socket) {
|
|
287
340
|
stopHeartbeat();
|
|
341
|
+
heartbeatAckWatchdog.start();
|
|
288
342
|
sendHeartbeat(socket);
|
|
289
343
|
heartbeatTimer = setInterval(() => sendHeartbeat(socket), connectionState.heartbeatMs);
|
|
344
|
+
transportHeartbeatTimer = setInterval(() => sendTransportPing(socket), TRANSPORT_PING_MS);
|
|
290
345
|
}
|
|
291
346
|
|
|
292
347
|
function sendSocketResponse(socket, requestId, payload, meta) {
|
|
@@ -296,8 +351,12 @@ function sendSocketResponse(socket, requestId, payload, meta) {
|
|
|
296
351
|
|
|
297
352
|
function scheduleReconnect(code, reason) {
|
|
298
353
|
if (stopping || reauthorizationRequired) return;
|
|
354
|
+
const stableConnectionMs = connectionState.connectedAt
|
|
355
|
+
? Math.max(0, Date.now() - Date.parse(connectionState.connectedAt))
|
|
356
|
+
: 0;
|
|
299
357
|
connectionState.markDisconnected(code, reason);
|
|
300
|
-
const
|
|
358
|
+
const isTransientAbnormalClose = Number(code) === 1006 && stableConnectionMs >= FAST_RECONNECT_STABLE_MS;
|
|
359
|
+
const delay = isTransientAbnormalClose ? FAST_RECONNECT_MS : connectionState.nextDelay();
|
|
301
360
|
connectionState.scheduleReconnect(delay);
|
|
302
361
|
clearReconnectTimer();
|
|
303
362
|
console.log(`Reconnect scheduled in ${delay}ms`);
|
|
@@ -363,6 +422,14 @@ function connect() {
|
|
|
363
422
|
console.log("Agent connected");
|
|
364
423
|
});
|
|
365
424
|
|
|
425
|
+
socket.on("pong", () => {
|
|
426
|
+
if (socket !== activeSocket) return;
|
|
427
|
+
if (transportPongDeadlineTimer) {
|
|
428
|
+
clearTimeout(transportPongDeadlineTimer);
|
|
429
|
+
transportPongDeadlineTimer = null;
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
366
433
|
socket.on("message", async (raw) => {
|
|
367
434
|
if (socket !== activeSocket) return;
|
|
368
435
|
let message;
|
|
@@ -372,6 +439,10 @@ function connect() {
|
|
|
372
439
|
return;
|
|
373
440
|
}
|
|
374
441
|
|
|
442
|
+
if (message?.control === "agent_heartbeat_ack") {
|
|
443
|
+
heartbeatAckWatchdog.acknowledge();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
375
446
|
if (message?.control === "credential_revoked") {
|
|
376
447
|
requireReauthorization("credential_revoked");
|
|
377
448
|
return;
|
|
@@ -437,10 +508,18 @@ function connect() {
|
|
|
437
508
|
});
|
|
438
509
|
|
|
439
510
|
socket.on("unexpected-response", (_request, response) => {
|
|
440
|
-
|
|
441
|
-
|
|
511
|
+
response.resume();
|
|
512
|
+
if (socket !== activeSocket || stopping || reauthorizationRequired) return;
|
|
513
|
+
const statusCode = Number(response.statusCode) || 0;
|
|
514
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
442
515
|
requireReauthorization("credential_rejected");
|
|
516
|
+
return;
|
|
443
517
|
}
|
|
518
|
+
activeSocket = null;
|
|
519
|
+
stopHeartbeat();
|
|
520
|
+
const reason = statusCode > 0 ? `unexpected_response_${statusCode}` : "unexpected_response";
|
|
521
|
+
connectionState.markSocketError(reason);
|
|
522
|
+
scheduleReconnect(null, reason);
|
|
444
523
|
});
|
|
445
524
|
|
|
446
525
|
socket.on("close", (code, reason) => {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const SESSION_PREFIX = "terminal";
|
|
2
|
+
|
|
3
|
+
export function createTerminalSessionId(processEpoch, sessionUuid) {
|
|
4
|
+
if (typeof processEpoch !== "string" || !processEpoch) throw new Error("invalid_process_epoch");
|
|
5
|
+
if (typeof sessionUuid !== "string" || !sessionUuid) throw new Error("invalid_session_uuid");
|
|
6
|
+
return `${SESSION_PREFIX}:${processEpoch}:${sessionUuid}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseTerminalSessionId(sessionId) {
|
|
10
|
+
if (typeof sessionId !== "string" || !sessionId) return null;
|
|
11
|
+
const match = /^terminal:([^:]+):([^:]+)$/.exec(sessionId);
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
return { processEpoch: match[1], sessionUuid: match[2] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function terminalSessionMiss(sessionId, processEpoch) {
|
|
17
|
+
const identity = parseTerminalSessionId(sessionId);
|
|
18
|
+
if (identity && identity.processEpoch !== processEpoch) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: "terminal_session_epoch_mismatch",
|
|
22
|
+
reason: "agent_process_restarted",
|
|
23
|
+
sessionId,
|
|
24
|
+
sessionProcessEpoch: identity.processEpoch,
|
|
25
|
+
currentProcessEpoch: processEpoch,
|
|
26
|
+
resumable: false,
|
|
27
|
+
recovery: "start_new_session",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: "session_not_found",
|
|
33
|
+
sessionId,
|
|
34
|
+
currentProcessEpoch: processEpoch,
|
|
35
|
+
resumable: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function terminalContinuityConfig(processEpoch, activeSessions) {
|
|
40
|
+
return {
|
|
41
|
+
processEpoch,
|
|
42
|
+
activeSessions,
|
|
43
|
+
scope: "same_process",
|
|
44
|
+
preservesSessionsAcrossSocketReconnect: true,
|
|
45
|
+
preservesSessionsAcrossProcessRestart: false,
|
|
46
|
+
resumableAfterProcessRestart: false,
|
|
47
|
+
sessionIdFormat: "terminal:<processEpoch>:<sessionUuid>",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -2,6 +2,11 @@ import { execFile, spawn } from "node:child_process";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
createTerminalSessionId,
|
|
7
|
+
terminalContinuityConfig,
|
|
8
|
+
terminalSessionMiss,
|
|
9
|
+
} from "./terminal-continuity.mjs";
|
|
5
10
|
|
|
6
11
|
const MAX_EXEC_BUFFER = 48 * 1024;
|
|
7
12
|
const MAX_SESSION_BUFFER = 256 * 1024;
|
|
@@ -524,6 +529,7 @@ export class TerminalManager {
|
|
|
524
529
|
this.platform = options.platform ?? process.platform;
|
|
525
530
|
this.shell = shellForPlatform(this.platform, options.shell);
|
|
526
531
|
this.sessions = new Map();
|
|
532
|
+
this.processEpoch = options.processEpoch ?? randomUUID();
|
|
527
533
|
this.batches = new Map();
|
|
528
534
|
this.batchQueue = [];
|
|
529
535
|
this.activeBatchJobs = 0;
|
|
@@ -646,12 +652,19 @@ export class TerminalManager {
|
|
|
646
652
|
return [...this.sessions.values()].map((session) => this.#summary(session));
|
|
647
653
|
}
|
|
648
654
|
|
|
655
|
+
getContinuityConfig() {
|
|
656
|
+
const activeSessions = [...this.sessions.values()]
|
|
657
|
+
.filter((session) => session.status === "running").length;
|
|
658
|
+
return terminalContinuityConfig(this.processEpoch, activeSessions);
|
|
659
|
+
}
|
|
660
|
+
|
|
649
661
|
observability() {
|
|
650
662
|
this.#cleanup();
|
|
651
663
|
return {
|
|
652
664
|
ok: true,
|
|
653
665
|
sessions: [...this.sessions.values()].map((session) => ({
|
|
654
666
|
sessionId: session.id,
|
|
667
|
+
processEpoch: session.processEpoch,
|
|
655
668
|
type: session.type,
|
|
656
669
|
status: session.status,
|
|
657
670
|
exitCode: session.exitCode,
|
|
@@ -688,6 +701,7 @@ export class TerminalManager {
|
|
|
688
701
|
read(sessionId, afterSeq = 0, maxChars = MAX_READ_CHARS) {
|
|
689
702
|
this.#cleanup();
|
|
690
703
|
const session = this.#get(sessionId);
|
|
704
|
+
if (!session) return terminalSessionMiss(sessionId, this.processEpoch);
|
|
691
705
|
const limit = Math.min(Math.max(Number(maxChars) || MAX_READ_CHARS, 1024), MAX_READ_CHARS);
|
|
692
706
|
const oldestSeq = session.chunks[0]?.seq ?? session.nextSeq;
|
|
693
707
|
const chunks = [];
|
|
@@ -711,6 +725,7 @@ export class TerminalManager {
|
|
|
711
725
|
|
|
712
726
|
write(sessionId, input, appendNewline = true) {
|
|
713
727
|
const session = this.#get(sessionId);
|
|
728
|
+
if (!session) return terminalSessionMiss(sessionId, this.processEpoch);
|
|
714
729
|
if (session.status !== "running") throw new Error("session_not_running");
|
|
715
730
|
if (typeof input !== "string" || input.length === 0 || input.length > MAX_INPUT_CHARS) {
|
|
716
731
|
throw new Error("invalid_input");
|
|
@@ -721,6 +736,7 @@ export class TerminalManager {
|
|
|
721
736
|
|
|
722
737
|
async kill(sessionId) {
|
|
723
738
|
const session = this.#get(sessionId);
|
|
739
|
+
if (!session) return terminalSessionMiss(sessionId, this.processEpoch);
|
|
724
740
|
if (session.status !== "running") return this.#summary(session);
|
|
725
741
|
|
|
726
742
|
await terminateProcessTree(this.platform, session.pid);
|
|
@@ -863,7 +879,8 @@ export class TerminalManager {
|
|
|
863
879
|
});
|
|
864
880
|
|
|
865
881
|
const session = {
|
|
866
|
-
id: randomUUID(),
|
|
882
|
+
id: createTerminalSessionId(this.processEpoch, randomUUID()),
|
|
883
|
+
processEpoch: this.processEpoch,
|
|
867
884
|
type,
|
|
868
885
|
command,
|
|
869
886
|
cwd,
|
|
@@ -1001,6 +1018,7 @@ export class TerminalManager {
|
|
|
1001
1018
|
#summary(session) {
|
|
1002
1019
|
return {
|
|
1003
1020
|
sessionId: session.id,
|
|
1021
|
+
processEpoch: session.processEpoch,
|
|
1004
1022
|
type: session.type,
|
|
1005
1023
|
command: session.command,
|
|
1006
1024
|
cwd: session.cwd,
|
|
@@ -1016,9 +1034,7 @@ export class TerminalManager {
|
|
|
1016
1034
|
|
|
1017
1035
|
#get(sessionId) {
|
|
1018
1036
|
if (typeof sessionId !== "string") throw new Error("invalid_session_id");
|
|
1019
|
-
|
|
1020
|
-
if (!session) throw new Error("session_not_found");
|
|
1021
|
-
return session;
|
|
1037
|
+
return this.sessions.get(sessionId) ?? null;
|
|
1022
1038
|
}
|
|
1023
1039
|
|
|
1024
1040
|
#getBatch(batchId) {
|
package/cli/index.mjs
CHANGED
|
@@ -231,8 +231,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
231
231
|
if (command === "resume") return lifecycleAction("resume");
|
|
232
232
|
if (command === "restart") return lifecycleAction("restart");
|
|
233
233
|
if (command === "remote") {
|
|
234
|
-
await remote(options);
|
|
235
|
-
return 0;
|
|
234
|
+
return (await remote(options)) || 0;
|
|
236
235
|
}
|
|
237
236
|
|
|
238
237
|
console.error(`Unknown command: ${command}`);
|
package/cli/remote.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { loadConfig, saveConfig } from "./config.mjs";
|
|
5
5
|
import { login, requestJson } from "./device-login.mjs";
|
|
6
|
+
import { acquireRunnerOwnership, RunnerAlreadyActiveError } from "./runner-ownership.mjs";
|
|
6
7
|
|
|
7
8
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
9
|
let stopping = false;
|
|
@@ -82,57 +83,69 @@ export async function remote(options = {}) {
|
|
|
82
83
|
config = await login(options);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
let ownership;
|
|
87
|
+
try {
|
|
88
|
+
ownership = await acquireRunnerOwnership({ relayUrl: config.relayUrl, agentId: config.agentId });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!(error instanceof RunnerAlreadyActiveError)) throw error;
|
|
91
|
+
console.error(error.message);
|
|
92
|
+
return 1;
|
|
88
93
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
printSummary(config);
|
|
95
|
-
|
|
96
|
-
while (!stopping) {
|
|
97
|
-
const child = spawnAgent(config);
|
|
98
|
-
|
|
99
|
-
const stop = () => {
|
|
100
|
-
stopping = true;
|
|
101
|
-
terminateChildTree(child);
|
|
102
|
-
};
|
|
103
|
-
process.once("SIGINT", stop);
|
|
104
|
-
process.once("SIGTERM", stop);
|
|
105
|
-
|
|
106
|
-
const result = await new Promise((resolve) => {
|
|
107
|
-
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
process.removeListener("SIGINT", stop);
|
|
111
|
-
process.removeListener("SIGTERM", stop);
|
|
112
|
-
if (stopping) break;
|
|
113
|
-
|
|
114
|
-
if (result.code === 2) {
|
|
115
|
-
stopping = true;
|
|
116
|
-
console.error("Agent authorization is no longer valid. Run chat-relay login --force, then chat-relay remote.");
|
|
117
|
-
break;
|
|
94
|
+
try {
|
|
95
|
+
if (options.allowedRoot && options.allowedRoot !== config.allowedRoots) {
|
|
96
|
+
config = { ...config, allowedRoots: options.allowedRoot };
|
|
97
|
+
saveConfig(config);
|
|
118
98
|
}
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (result.code === 4) {
|
|
126
|
-
console.log("Agent restart requested. Restarting local agent...");
|
|
127
|
-
continue;
|
|
99
|
+
if (options.desktopEnabled !== undefined && Boolean(options.desktopEnabled) !== Boolean(config.desktopEnabled)) {
|
|
100
|
+
config = { ...config, desktopEnabled: Boolean(options.desktopEnabled) };
|
|
101
|
+
saveConfig(config);
|
|
128
102
|
}
|
|
129
103
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
);
|
|
134
|
-
|
|
104
|
+
printSummary(config);
|
|
105
|
+
|
|
106
|
+
while (!stopping) {
|
|
107
|
+
const child = spawnAgent(config);
|
|
108
|
+
|
|
109
|
+
const stop = () => {
|
|
110
|
+
stopping = true;
|
|
111
|
+
terminateChildTree(child);
|
|
112
|
+
};
|
|
113
|
+
process.once("SIGINT", stop);
|
|
114
|
+
process.once("SIGTERM", stop);
|
|
115
|
+
|
|
116
|
+
const result = await new Promise((resolve) => {
|
|
117
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
process.removeListener("SIGINT", stop);
|
|
121
|
+
process.removeListener("SIGTERM", stop);
|
|
122
|
+
if (stopping) break;
|
|
123
|
+
|
|
124
|
+
if (result.code === 2) {
|
|
125
|
+
stopping = true;
|
|
126
|
+
console.error("Agent authorization is no longer valid. Run chat-relay login --force, then chat-relay remote.");
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (result.code === 3) {
|
|
130
|
+
stopping = true;
|
|
131
|
+
console.error("Agent protocol is incompatible with the relay. Update Chat Relay, then run chat-relay remote again.");
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (result.code === 4) {
|
|
136
|
+
console.log("Agent restart requested. Restarting local agent...");
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (shouldRestartAgent(result, stopping)) {
|
|
141
|
+
console.error(
|
|
142
|
+
`Agent stopped (code=${result.code ?? "null"}, signal=${result.signal ?? "none"}). Restarting in 2s...`,
|
|
143
|
+
);
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
145
|
+
}
|
|
135
146
|
}
|
|
147
|
+
} finally {
|
|
148
|
+
await ownership.release();
|
|
136
149
|
}
|
|
137
150
|
}
|
|
138
151
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { configDir } from "./config.mjs";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_STARTUP_GRACE_MS = 1_500;
|
|
10
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 400;
|
|
11
|
+
|
|
12
|
+
export class RunnerAlreadyActiveError extends Error {
|
|
13
|
+
constructor({ agentId, relayUrl, pid }) {
|
|
14
|
+
const owner = Number.isInteger(pid) ? ` (PID ${pid})` : "";
|
|
15
|
+
super(
|
|
16
|
+
`Chat Relay agent "${agentId}" is already running for ${relayUrl}${owner}. ` +
|
|
17
|
+
"Stop the existing runner before starting another one.",
|
|
18
|
+
);
|
|
19
|
+
this.name = "RunnerAlreadyActiveError";
|
|
20
|
+
this.code = "CHAT_RELAY_RUNNER_ACTIVE";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeRelayUrl(relayUrl) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(String(relayUrl));
|
|
27
|
+
url.hash = "";
|
|
28
|
+
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
29
|
+
return url.toString();
|
|
30
|
+
} catch {
|
|
31
|
+
return String(relayUrl).replace(/\/+$/, "");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ownershipKey(relayUrl, agentId) {
|
|
36
|
+
return createHash("sha256")
|
|
37
|
+
.update(normalizeRelayUrl(relayUrl))
|
|
38
|
+
.update("\0")
|
|
39
|
+
.update(String(agentId))
|
|
40
|
+
.digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function socketEndpoint(key, token) {
|
|
44
|
+
const name = `chat-relay-${createHash("sha256").update(key).update(token).digest("hex").slice(0, 32)}`;
|
|
45
|
+
if (process.platform === "win32") return `\\\\.\\pipe\\${name}`;
|
|
46
|
+
const base = Buffer.byteLength(path.join(os.tmpdir(), `${name}.sock`)) < 100 ? os.tmpdir() : "/tmp";
|
|
47
|
+
return path.join(base, `${name}.sock`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readMetadata(lockDir) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function delay(milliseconds) {
|
|
59
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function probeOwner(metadata, timeoutMs) {
|
|
63
|
+
if (!metadata?.endpoint || !metadata?.token) return Promise.resolve(false);
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
let settled = false;
|
|
66
|
+
let response = "";
|
|
67
|
+
const socket = net.createConnection(metadata.endpoint);
|
|
68
|
+
const finish = (active) => {
|
|
69
|
+
if (settled) return;
|
|
70
|
+
settled = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
socket.destroy();
|
|
73
|
+
resolve(active);
|
|
74
|
+
};
|
|
75
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
76
|
+
socket.setEncoding("utf8");
|
|
77
|
+
socket.once("connect", () => socket.write("PING\n"));
|
|
78
|
+
socket.on("data", (chunk) => {
|
|
79
|
+
response += chunk;
|
|
80
|
+
if (response.includes("\n")) finish(response.trim() === `OWNER ${metadata.token}`);
|
|
81
|
+
});
|
|
82
|
+
socket.once("error", () => finish(false));
|
|
83
|
+
socket.once("end", () => finish(response.trim() === `OWNER ${metadata.token}`));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function listen(server, endpoint) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const onError = (error) => reject(error);
|
|
90
|
+
server.once("error", onError);
|
|
91
|
+
server.listen(endpoint, () => {
|
|
92
|
+
server.removeListener("error", onError);
|
|
93
|
+
resolve();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function close(server) {
|
|
99
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function removeSocket(endpoint) {
|
|
103
|
+
if (process.platform !== "win32") fs.rmSync(endpoint, { force: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function removeStaleSocket(endpoint) {
|
|
107
|
+
if (process.platform === "win32" || typeof endpoint !== "string") return;
|
|
108
|
+
const allowedDirectories = new Set([path.resolve(os.tmpdir()), path.resolve("/tmp")]);
|
|
109
|
+
if (!allowedDirectories.has(path.resolve(path.dirname(endpoint)))) return;
|
|
110
|
+
if (!/^chat-relay-[a-f0-9]{32}\.sock$/.test(path.basename(endpoint))) return;
|
|
111
|
+
fs.rmSync(endpoint, { force: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function reclaim(lockDir) {
|
|
115
|
+
const staleDir = `${lockDir}.stale-${randomUUID()}`;
|
|
116
|
+
try {
|
|
117
|
+
fs.renameSync(lockDir, staleDir);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (["ENOENT", "EACCES", "EPERM"].includes(error.code)) return false;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
fs.rmSync(staleDir, { recursive: true, force: true });
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function acquireRunnerOwnership({
|
|
127
|
+
relayUrl,
|
|
128
|
+
agentId,
|
|
129
|
+
directory = configDir(),
|
|
130
|
+
startupGraceMs = DEFAULT_STARTUP_GRACE_MS,
|
|
131
|
+
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
132
|
+
} = {}) {
|
|
133
|
+
if (!relayUrl || !agentId) throw new Error("Runner ownership requires relayUrl and agentId");
|
|
134
|
+
|
|
135
|
+
const key = ownershipKey(relayUrl, agentId);
|
|
136
|
+
const locksDir = path.join(directory, "runner-locks");
|
|
137
|
+
const lockDir = path.join(locksDir, key);
|
|
138
|
+
fs.mkdirSync(locksDir, { recursive: true, mode: 0o700 });
|
|
139
|
+
|
|
140
|
+
while (true) {
|
|
141
|
+
try {
|
|
142
|
+
fs.mkdirSync(lockDir, { mode: 0o700 });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error.code !== "EEXIST") throw error;
|
|
145
|
+
|
|
146
|
+
const metadata = readMetadata(lockDir);
|
|
147
|
+
if (await probeOwner(metadata, probeTimeoutMs)) {
|
|
148
|
+
throw new RunnerAlreadyActiveError({ agentId, relayUrl, pid: metadata.pid });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let ageMs = Number.POSITIVE_INFINITY;
|
|
152
|
+
try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch {}
|
|
153
|
+
if (ageMs < startupGraceMs) {
|
|
154
|
+
await delay(Math.min(50, Math.max(1, startupGraceMs - ageMs)));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (reclaim(lockDir)) removeStaleSocket(metadata?.endpoint);
|
|
158
|
+
else await delay(50);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const token = randomUUID();
|
|
163
|
+
const endpoint = socketEndpoint(key, token);
|
|
164
|
+
const metadata = {
|
|
165
|
+
version: 1,
|
|
166
|
+
pid: process.pid,
|
|
167
|
+
token,
|
|
168
|
+
endpoint,
|
|
169
|
+
relayUrl: normalizeRelayUrl(relayUrl),
|
|
170
|
+
agentId: String(agentId),
|
|
171
|
+
createdAt: new Date().toISOString(),
|
|
172
|
+
};
|
|
173
|
+
const server = net.createServer((socket) => {
|
|
174
|
+
socket.setEncoding("utf8");
|
|
175
|
+
socket.once("data", () => socket.end(`OWNER ${token}\n`));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify(metadata), { mode: 0o600 });
|
|
180
|
+
removeSocket(endpoint);
|
|
181
|
+
await listen(server, endpoint);
|
|
182
|
+
server.unref();
|
|
183
|
+
if (process.platform !== "win32") {
|
|
184
|
+
try { fs.chmodSync(endpoint, 0o600); } catch {}
|
|
185
|
+
}
|
|
186
|
+
if (readMetadata(lockDir)?.token !== token) {
|
|
187
|
+
await close(server);
|
|
188
|
+
removeSocket(endpoint);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
try { await close(server); } catch {}
|
|
193
|
+
removeSocket(endpoint);
|
|
194
|
+
if (readMetadata(lockDir)?.token === token) fs.rmSync(lockDir, { recursive: true, force: true });
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let released = false;
|
|
199
|
+
return {
|
|
200
|
+
async release() {
|
|
201
|
+
if (released) return;
|
|
202
|
+
released = true;
|
|
203
|
+
await close(server);
|
|
204
|
+
removeSocket(endpoint);
|
|
205
|
+
if (readMetadata(lockDir)?.token === token) fs.rmSync(lockDir, { recursive: true, force: true });
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anusornneal/chat-relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Zero-checkout remote MCP agent for ChatGPT with terminal, filesystem, process, multi-user, and multi-agent support.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"agent": "node --env-file-if-exists=.dev.vars agent/local-agent.mjs",
|
|
31
31
|
"admin": "node scripts/admin.mjs",
|
|
32
32
|
"test:terminal": "node test/mcp-terminal-smoke.mjs",
|
|
33
|
+
"test:stability": "node --experimental-strip-types test/relay-stability-smoke.mjs",
|
|
33
34
|
"test:platform": "node test/platform-smoke.mjs",
|
|
34
35
|
"test:full": "node test/multiuser-smoke.mjs",
|
|
35
36
|
"test:device-auth": "node test/device-auth-smoke.mjs",
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
"test:desktop": "node test/mcp-desktop-smoke.mjs",
|
|
47
48
|
"test:device-management": "node test/device-management-smoke.mjs",
|
|
48
49
|
"test:submission": "node test/submission-smoke.mjs",
|
|
50
|
+
"test:agent-state": "node --experimental-strip-types --test test/agent-state.test.mjs",
|
|
49
51
|
"verify:review": "node scripts/verify-review-artifacts.mjs"
|
|
50
52
|
},
|
|
51
53
|
"dependencies": {
|