@anusornneal/chat-relay 0.9.2 → 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.
@@ -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
- this.lastCloseCode = Number.isFinite(Number(code)) ? Number(code) : null;
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
+ }
@@ -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
- activeSessions: terminals.list().filter((session) => session.status === "running").length,
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
- if (!heartbeatTimer) return;
254
- clearInterval(heartbeatTimer);
255
- heartbeatTimer = null;
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 delay = connectionState.nextDelay();
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
- if (response.statusCode === 401 || response.statusCode === 403) {
441
- response.resume();
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
- const session = this.sessions.get(sessionId);
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
- if (options.allowedRoot && options.allowedRoot !== config.allowedRoots) {
86
- config = { ...config, allowedRoots: options.allowedRoot };
87
- saveConfig(config);
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
- if (options.desktopEnabled !== undefined && Boolean(options.desktopEnabled) !== Boolean(config.desktopEnabled)) {
90
- config = { ...config, desktopEnabled: Boolean(options.desktopEnabled) };
91
- saveConfig(config);
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 (result.code === 3) {
120
- stopping = true;
121
- console.error("Agent protocol is incompatible with the relay. Update Chat Relay, then run chat-relay remote again.");
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
- if (shouldRestartAgent(result, stopping)) {
131
- console.error(
132
- `Agent stopped (code=${result.code ?? "null"}, signal=${result.signal ?? "none"}). Restarting in 2s...`,
133
- );
134
- await new Promise((resolve) => setTimeout(resolve, 2000));
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.9.2",
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": {