agent-dir 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,11 @@ import { createMcpHandler } from "./mcp.js";
5
5
  import { TelemetryRecorder } from "./telemetry/recorder.js";
6
6
  import { deleteFile, listFiles, readFile, writeFile } from "./tools/files.js";
7
7
  const MAX_BODY_BYTES = 10 * 1024 * 1024;
8
+ const REQUEST_BODY_TIMEOUT_MS = 30_000;
9
+ const REQUEST_TIMEOUT_MS = 120_000;
10
+ const HEADERS_TIMEOUT_MS = 30_000;
11
+ const KEEP_ALIVE_TIMEOUT_MS = 5_000;
12
+ const HEALTH_PATH = "/__health";
8
13
  export function startServer({ root, port, token, commandConfig = {}, telemetry = { level: "none" }, }) {
9
14
  const mcp = createMcpHandler(root, commandConfig, telemetry);
10
15
  const httpTelemetry = new TelemetryRecorder({
@@ -15,13 +20,20 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
15
20
  });
16
21
  const server = http.createServer(async (req, res) => {
17
22
  const startedAt = performance.now();
23
+ const requestController = new AbortController();
24
+ req.once("aborted", () => requestController.abort());
25
+ res.once("close", () => {
26
+ if (!res.writableFinished)
27
+ requestController.abort();
28
+ });
18
29
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
19
30
  let authenticated = false;
20
31
  try {
21
- const body = await readBody(req);
32
+ const body = await readBody(req, requestController.signal);
22
33
  const requestInit = {
23
34
  method: req.method ?? "GET",
24
35
  headers: toFetchHeaders(req.headers),
36
+ signal: requestController.signal,
25
37
  };
26
38
  if (body.length)
27
39
  requestInit.body = body;
@@ -39,6 +51,10 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
39
51
  recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 401, authenticated, startedAt);
40
52
  return;
41
53
  }
54
+ if (url.pathname === HEALTH_PATH && req.method === "HEAD") {
55
+ sendText(res, 200, "ok\n");
56
+ return;
57
+ }
42
58
  if (url.pathname === "/mcp") {
43
59
  if (req.method !== "POST") {
44
60
  res.setHeader("allow", "POST");
@@ -117,7 +133,12 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
117
133
  if (req.method === "DELETE") {
118
134
  await deleteFile(root, relative);
119
135
  sendText(res, 200, "File deleted successfully\n");
120
- logRequest({ method: req.method, path: url.pathname, status: 200, detail: "REST DELETE" });
136
+ logRequest({
137
+ method: req.method,
138
+ path: url.pathname,
139
+ status: 200,
140
+ detail: "REST DELETE",
141
+ });
121
142
  recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
122
143
  return;
123
144
  }
@@ -131,8 +152,16 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
131
152
  recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 404, authenticated, startedAt);
132
153
  }
133
154
  catch (error) {
155
+ if (requestController.signal.aborted || res.destroyed)
156
+ return;
134
157
  const code = error.code;
135
- const status = code === "ENOENT" ? 404 : code === "PAYLOAD_TOO_LARGE" ? 413 : 500;
158
+ const status = code === "ENOENT"
159
+ ? 404
160
+ : code === "PAYLOAD_TOO_LARGE"
161
+ ? 413
162
+ : code === "REQUEST_TIMEOUT"
163
+ ? 408
164
+ : 500;
136
165
  sendText(res, status, `${error instanceof Error ? error.message : String(error)}\n`);
137
166
  logRequest({
138
167
  method: req.method ?? "UNKNOWN",
@@ -143,6 +172,9 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
143
172
  recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, status, authenticated, startedAt);
144
173
  }
145
174
  });
175
+ server.requestTimeout = REQUEST_TIMEOUT_MS;
176
+ server.headersTimeout = HEADERS_TIMEOUT_MS;
177
+ server.keepAliveTimeout = KEEP_ALIVE_TIMEOUT_MS;
146
178
  return new Promise((resolve, reject) => {
147
179
  server.once("error", reject);
148
180
  server.listen(port, "127.0.0.1", () => resolve({
@@ -531,12 +563,39 @@ function toFetchHeaders(headers) {
531
563
  }
532
564
  return result;
533
565
  }
534
- function readBody(req) {
566
+ function readBody(req, signal) {
535
567
  return new Promise((resolve, reject) => {
536
568
  const chunks = [];
537
569
  let size = 0;
538
570
  let tooLarge = false;
539
- req.on("data", (chunk) => {
571
+ let settled = false;
572
+ const timer = setTimeout(() => {
573
+ const error = new Error(`Request body timed out after ${REQUEST_BODY_TIMEOUT_MS / 1000}s.`);
574
+ error.code = "REQUEST_TIMEOUT";
575
+ finish(error);
576
+ }, REQUEST_BODY_TIMEOUT_MS);
577
+ const cleanup = () => {
578
+ clearTimeout(timer);
579
+ req.removeListener("aborted", onAborted);
580
+ req.removeListener("error", onError);
581
+ req.removeListener("data", onData);
582
+ req.removeListener("end", onEnd);
583
+ signal.removeEventListener("abort", onSignalAbort);
584
+ };
585
+ const finish = (error, value) => {
586
+ if (settled)
587
+ return;
588
+ settled = true;
589
+ cleanup();
590
+ if (error)
591
+ reject(error);
592
+ else
593
+ resolve(value ?? Buffer.alloc(0));
594
+ };
595
+ const onAborted = () => finish(new Error("Request aborted by the client."));
596
+ const onError = (error) => finish(error);
597
+ const onSignalAbort = () => finish(new Error("Request aborted by the client."));
598
+ const onData = (chunk) => {
540
599
  if (tooLarge)
541
600
  return;
542
601
  const buffer = Buffer.from(chunk);
@@ -546,17 +605,25 @@ function readBody(req) {
546
605
  return;
547
606
  }
548
607
  chunks.push(buffer);
549
- });
550
- req.on("end", () => {
608
+ };
609
+ const onEnd = () => {
551
610
  if (tooLarge) {
552
611
  const error = new Error(`Request body exceeds the ${MAX_BODY_BYTES / 1024 / 1024} MiB limit.`);
553
612
  error.code = "PAYLOAD_TOO_LARGE";
554
- reject(error);
613
+ finish(error);
555
614
  return;
556
615
  }
557
- resolve(Buffer.concat(chunks));
558
- });
559
- req.on("error", reject);
616
+ finish(undefined, Buffer.concat(chunks));
617
+ };
618
+ if (signal.aborted) {
619
+ onSignalAbort();
620
+ return;
621
+ }
622
+ req.once("aborted", onAborted);
623
+ req.once("error", onError);
624
+ signal.addEventListener("abort", onSignalAbort, { once: true });
625
+ req.on("data", onData);
626
+ req.on("end", onEnd);
560
627
  });
561
628
  }
562
629
  function sendText(res, status, text, contentType = "text/plain; charset=utf-8") {
@@ -57,6 +57,7 @@ export function sanitizeAnonymousEvent(event) {
57
57
  return {
58
58
  event: "tunnel",
59
59
  state: event.state,
60
+ ...(event.healthState === undefined ? {} : { healthState: event.healthState }),
60
61
  ...(event.attempt === undefined ? {} : { attempt: boundedInteger(event.attempt) }),
61
62
  ...(event.attempts === undefined ? {} : { attempts: boundedInteger(event.attempts) }),
62
63
  ...(event.durationMs === undefined ? {} : { durationMs: boundedNumber(event.durationMs) }),
@@ -46,7 +46,8 @@ export interface TelemetryCommandEvent {
46
46
  }
47
47
  export interface TelemetryTunnelEvent {
48
48
  event: "tunnel";
49
- state: "online" | "disconnected" | "reconnecting" | "reconnected" | "failed";
49
+ state: "online" | "disconnected" | "reconnecting" | "reconnected" | "failed" | "health";
50
+ healthState?: "healthy" | "degraded" | "recovered";
50
51
  attempt?: number;
51
52
  attempts?: number;
52
53
  durationMs?: number;
@@ -6,6 +6,7 @@ const HEALTH_TIMEOUT_MS = 5_000;
6
6
  const HEALTH_FAILURE_THRESHOLD = 2;
7
7
  const RECONNECT_DELAY_MS = 1_000;
8
8
  const MAX_RECONNECT_ATTEMPTS = 5;
9
+ const HEALTH_PATH = "/__health";
9
10
  const ANSI_ESCAPE = new RegExp(String.fromCharCode(0x1b) + String.raw `\[[0-?]*[ -/]*[@-~]`, "g");
10
11
  function cleanOutput(value) {
11
12
  return value.toString().replace(ANSI_ESCAPE, "");
@@ -55,6 +56,7 @@ export function startTunnel(options) {
55
56
  let healthTimer;
56
57
  let stopped = false;
57
58
  let healthFailures = 0;
59
+ let healthState = "healthy";
58
60
  const record = (event) => {
59
61
  if (!telemetry)
60
62
  return;
@@ -75,6 +77,8 @@ export function startTunnel(options) {
75
77
  });
76
78
  else if (event.type === "reconnected")
77
79
  telemetry.record({ event: "tunnel", state: "reconnected", attempt: event.attempt });
80
+ else if (event.type === "health")
81
+ telemetry.record({ event: "tunnel", state: "health", healthState: event.state });
78
82
  else
79
83
  telemetry.record({
80
84
  event: "tunnel",
@@ -106,9 +110,16 @@ export function startTunnel(options) {
106
110
  writeLine(` MCP endpoint : ${event.url}/mcp`);
107
111
  writeLine();
108
112
  }
113
+ else if (event.type === "health") {
114
+ if (event.state === "degraded")
115
+ writeLine(" ⚠ TUNNEL HEALTH DEGRADED");
116
+ else if (event.state === "recovered")
117
+ writeLine(" ✓ TUNNEL HEALTH RECOVERED");
118
+ }
109
119
  else {
110
- writeLine(` ✖ TUNNEL OFFLINE ${event.reason}`);
111
- writeLine(` Reconnect attempts exhausted: ${event.attempts}`);
120
+ writeLine(` ⚠ TUNNEL RECOVERY CYCLE EXHAUSTED ${event.reason}`);
121
+ writeLine(` Attempts completed : ${event.attempts}`);
122
+ writeLine(" Starting a fresh reconnect cycle.");
112
123
  }
113
124
  };
114
125
  const cleanupTimers = () => {
@@ -135,17 +146,25 @@ export function startTunnel(options) {
135
146
  if (!url || stopped || reconnecting)
136
147
  return;
137
148
  try {
138
- const response = await fetch(url, {
149
+ const response = await fetch(new URL(HEALTH_PATH, url), {
139
150
  method: "HEAD",
140
151
  ...(options.token ? { headers: { authorization: `Bearer ${options.token}` } } : {}),
141
152
  signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
142
153
  });
154
+ if (!response.ok)
155
+ throw new Error(`Health check returned HTTP ${response.status}.`);
143
156
  healthFailures = 0;
144
- if (!response)
145
- return;
157
+ if (healthState === "degraded") {
158
+ healthState = "healthy";
159
+ emit({ type: "health", state: "recovered" });
160
+ }
146
161
  }
147
162
  catch {
148
163
  healthFailures += 1;
164
+ if (healthFailures === 1 && healthState === "healthy") {
165
+ healthState = "degraded";
166
+ emit({ type: "health", state: "degraded" });
167
+ }
149
168
  if (healthFailures >= HEALTH_FAILURE_THRESHOLD) {
150
169
  healthFailures = 0;
151
170
  handleDisconnect("Public tunnel health check failed.");
@@ -157,6 +176,7 @@ export function startTunnel(options) {
157
176
  const spawnTunnel = (attempt) => {
158
177
  if (stopped)
159
178
  return;
179
+ reconnecting = false;
160
180
  const args = ["http", String(options.port)];
161
181
  if (!options.random && options.subdomain)
162
182
  args.push("--subdomain", options.subdomain);
@@ -257,10 +277,12 @@ export function startTunnel(options) {
257
277
  if (stopped || reconnecting)
258
278
  return;
259
279
  reconnecting = true;
280
+ if (child && !child.killed)
281
+ child.kill("SIGTERM");
260
282
  emit({ type: "disconnected", reason });
261
283
  if (attempts >= MAX_RECONNECT_ATTEMPTS) {
262
284
  emit({ type: "failed", reason, attempts });
263
- return;
285
+ attempts = 0;
264
286
  }
265
287
  attempts += 1;
266
288
  emit({ type: "reconnecting", attempt: attempts, reason });
@@ -72,4 +72,7 @@ export type TunnelEvent = {
72
72
  type: "failed";
73
73
  reason: string;
74
74
  attempts: number;
75
+ } | {
76
+ type: "health";
77
+ state: "healthy" | "degraded" | "recovered";
75
78
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dir",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Expose a local project to an AI agent through authenticated MCP, filesystem tools, and allowlisted development commands",
5
5
  "repository": {
6
6
  "type": "git",