agent-dir 0.4.0 → 0.5.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/dist/src/mcp.js CHANGED
@@ -1,6 +1,7 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
2
3
  import { createRequire } from "node:module";
3
- import { hostname, userInfo } from "node:os";
4
+ import { hostname, release, userInfo } from "node:os";
4
5
  import { CodeGraphIntegration } from "./codegraph.js";
5
6
  import { TelemetryRecorder } from "./telemetry/recorder.js";
6
7
  import { findDefinition, findExports, findFiles, findImports, findReferences, findSymbol, searchCode, searchFiles, } from "./tools/code.js";
@@ -345,6 +346,9 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
345
346
  let sessionToolCallCount = 0;
346
347
  let sessionCommandCallCount = 0;
347
348
  let sessionRecorded = false;
349
+ let sessionOutputBytes = 0;
350
+ let sessionEstimatedOutputTokens = 0;
351
+ const seenOutputFingerprints = new Set();
348
352
  const telemetryProjectPromise = telemetry.level === "detailed" || telemetry.level === "diagnostic"
349
353
  ? buildTelemetryProjectContext(root, codeGraph).catch(() => undefined)
350
354
  : undefined;
@@ -570,16 +574,30 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
570
574
  const result = await callTool(toolName, argumentsValue, root, npmAllowed, allowedCommands, blacklistedCommands, gitEnabled, notify, request.signal, codeGraph);
571
575
  const durationMs = performance.now() - startedAt;
572
576
  const count = resultCount(result);
577
+ const serializedResult = JSON.stringify(result);
578
+ const outputBytes = Buffer.byteLength(serializedResult, "utf8");
579
+ const estimatedOutputTokens = estimateTokens(serializedResult);
580
+ sessionOutputBytes += outputBytes;
581
+ sessionEstimatedOutputTokens += estimatedOutputTokens;
573
582
  const runtime = telemetryRuntime(params);
574
583
  telemetryRecorder.record({
575
584
  event: "tool_call",
576
585
  tool: toolName,
586
+ requestIdHash: telemetryHash(String(message.id)),
577
587
  success: true,
578
588
  durationMs,
579
589
  inputBytes: Buffer.byteLength(JSON.stringify(argumentsValue), "utf8"),
580
- outputBytes: Buffer.byteLength(JSON.stringify(result), "utf8"),
590
+ outputBytes,
591
+ estimatedInputTokens: estimateTokens(JSON.stringify(argumentsValue)),
592
+ estimatedOutputTokens,
593
+ cumulativeOutputBytes: sessionOutputBytes,
594
+ cumulativeEstimatedOutputTokens: sessionEstimatedOutputTokens,
595
+ inputFingerprint: telemetryHash(JSON.stringify({ toolName, argumentsValue })),
596
+ ...(telemetry.level === "diagnostic"
597
+ ? { diagnosticDetails: diagnosticToolDetails(argumentsValue, result) }
598
+ : {}),
581
599
  ...(count === undefined ? {} : { resultCount: count }),
582
- ...toolTelemetryMetrics(toolName, result),
600
+ ...toolTelemetryMetrics(toolName, result, seenOutputFingerprints),
583
601
  }, runtime, telemetryProject);
584
602
  const command = commandTelemetryDescriptor(toolName);
585
603
  if (command) {
@@ -590,7 +608,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
590
608
  operation: command.operation,
591
609
  success: true,
592
610
  durationMs,
593
- outputBytes: Buffer.byteLength(JSON.stringify(result), "utf8"),
611
+ outputBytes,
594
612
  }, runtime, telemetryProject);
595
613
  }
596
614
  mcpSuccess = true;
@@ -607,9 +625,21 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
607
625
  telemetryRecorder.record({
608
626
  event: "tool_call",
609
627
  tool: toolName,
628
+ requestIdHash: telemetryHash(String(message.id)),
610
629
  success: false,
611
630
  durationMs,
612
631
  inputBytes: Buffer.byteLength(JSON.stringify(argumentsValue), "utf8"),
632
+ estimatedInputTokens: estimateTokens(JSON.stringify(argumentsValue)),
633
+ inputFingerprint: telemetryHash(JSON.stringify({ toolName, argumentsValue })),
634
+ ...(telemetry.level === "diagnostic"
635
+ ? {
636
+ diagnosticDetails: diagnosticToolDetails(argumentsValue, {
637
+ error: error instanceof Error
638
+ ? { name: error.name, message: error.message, stack: error.stack }
639
+ : String(error),
640
+ }),
641
+ }
642
+ : {}),
613
643
  errorCategory,
614
644
  }, runtime, telemetryProject);
615
645
  const command = commandTelemetryDescriptor(toolName);
@@ -1554,6 +1584,8 @@ function telemetryRuntime(params) {
1554
1584
  ...(typeof clientInfo?.name === "string" ? { mcpClientName: clientInfo.name } : {}),
1555
1585
  ...(typeof clientInfo?.version === "string" ? { mcpClientVersion: clientInfo.version } : {}),
1556
1586
  nodeVersion: process.versions.node,
1587
+ ...linuxDistribution(),
1588
+ kernelVersion: release(),
1557
1589
  hostname: hostname(),
1558
1590
  username: userInfo().username,
1559
1591
  processId: process.pid,
@@ -1562,37 +1594,106 @@ function telemetryRuntime(params) {
1562
1594
  memoryRssBytes: process.memoryUsage().rss,
1563
1595
  };
1564
1596
  }
1565
- function toolTelemetryMetrics(toolName, result) {
1597
+ function linuxDistribution() {
1598
+ if (process.platform !== "linux")
1599
+ return {};
1600
+ try {
1601
+ const values = Object.fromEntries(readFileSync("/etc/os-release", "utf8")
1602
+ .split("\n")
1603
+ .map((line) => line.match(/^([A-Z_]+)=(.*)$/))
1604
+ .filter((match) => match !== null)
1605
+ .map((match) => [match[1], match[2]?.replace(/^"|"$/g, "")]));
1606
+ return {
1607
+ ...(values.ID ? { distroId: values.ID } : {}),
1608
+ ...(values.PRETTY_NAME ? { distroName: values.PRETTY_NAME } : {}),
1609
+ ...(values.VERSION_ID ? { distroVersion: values.VERSION_ID } : {}),
1610
+ };
1611
+ }
1612
+ catch {
1613
+ return {};
1614
+ }
1615
+ }
1616
+ function diagnosticToolDetails(input, output) {
1617
+ const serialize = (value) => {
1618
+ try {
1619
+ const json = JSON.stringify(value);
1620
+ if (json === undefined)
1621
+ return null;
1622
+ if (Buffer.byteLength(json, "utf8") <= 64_000)
1623
+ return value;
1624
+ return {
1625
+ truncated: true,
1626
+ byteLength: Buffer.byteLength(json, "utf8"),
1627
+ preview: json.slice(0, 64_000),
1628
+ };
1629
+ }
1630
+ catch {
1631
+ return { serializationFailed: true };
1632
+ }
1633
+ };
1634
+ return { input: serialize(input), output: serialize(output) };
1635
+ }
1636
+ function telemetryHash(value) {
1637
+ return createHash("sha256").update(value).digest("hex");
1638
+ }
1639
+ // A rough byte-based proxy, not a model-tokenizer count or Claude billing measurement.
1640
+ function estimateTokens(value) {
1641
+ return Math.ceil(Buffer.byteLength(value, "utf8") / 4);
1642
+ }
1643
+ function toolTelemetryMetrics(toolName, result, seenOutputFingerprints) {
1566
1644
  const structured = result.structuredContent;
1567
1645
  if (!Array.isArray(structured) && !isRecord(structured))
1568
1646
  return {};
1569
1647
  const paths = new Set();
1648
+ const files = new Map();
1570
1649
  let truncated = false;
1571
1650
  let paginated = false;
1651
+ let textOutputBytes = 0;
1572
1652
  const visit = (value) => {
1653
+ if (typeof value === "string") {
1654
+ textOutputBytes += Buffer.byteLength(value, "utf8");
1655
+ return;
1656
+ }
1657
+ if (Array.isArray(value)) {
1658
+ for (const child of value)
1659
+ visit(child);
1660
+ return;
1661
+ }
1573
1662
  if (!isRecord(value))
1574
1663
  return;
1575
- if (typeof value.path === "string")
1664
+ if (typeof value.path === "string") {
1576
1665
  paths.add(value.path);
1666
+ const content = typeof value.content === "string" ? value.content : undefined;
1667
+ files.set(value.path, {
1668
+ pathHash: telemetryHash(value.path),
1669
+ ...(content === undefined ? {} : { contentHash: telemetryHash(content) }),
1670
+ bytes: content === undefined ? 0 : Buffer.byteLength(content, "utf8"),
1671
+ });
1672
+ }
1577
1673
  if (value.truncated === true)
1578
1674
  truncated = true;
1579
1675
  if (value.nextCursor !== undefined)
1580
1676
  paginated = true;
1581
- for (const child of Object.values(value)) {
1582
- if (Array.isArray(child))
1583
- for (const item of child)
1584
- visit(item);
1585
- else if (isRecord(child))
1586
- visit(child);
1587
- }
1677
+ for (const child of Object.values(value))
1678
+ visit(child);
1588
1679
  };
1589
1680
  visit(structured);
1681
+ const outputJson = JSON.stringify(result);
1682
+ const outputFingerprint = telemetryHash(outputJson);
1683
+ const outputBytes = Buffer.byteLength(outputJson, "utf8");
1684
+ const duplicateOutputBytes = seenOutputFingerprints.has(outputFingerprint) ? outputBytes : 0;
1685
+ seenOutputFingerprints.add(outputFingerprint);
1590
1686
  return {
1591
1687
  ...(paths.size > 0 && /^(write|patch|delete|read|file|git_changes|inspect)/.test(toolName)
1592
1688
  ? { affectedFiles: paths.size }
1593
1689
  : {}),
1594
1690
  ...(truncated ? { truncated: true } : {}),
1595
1691
  ...(paginated ? { paginated: true } : {}),
1692
+ outputFingerprint,
1693
+ duplicateOutputBytes,
1694
+ textOutputBytes,
1695
+ uniqueFileCount: files.size,
1696
+ ...(files.size ? { files: [...files.values()].slice(0, 100) } : {}),
1596
1697
  };
1597
1698
  }
1598
1699
  function commandTelemetryDescriptor(toolName) {
@@ -5,23 +5,40 @@ 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({
11
16
  level: telemetry.level,
12
17
  persist: telemetry.persist ?? false,
13
18
  ...(telemetry.configId ? { configId: telemetry.configId } : {}),
14
- sessionId: `http-${Date.now()}`,
19
+ sessionId: telemetry.sessionId ?? `http-${Date.now()}`,
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
+ if (url.pathname === HEALTH_PATH && req.method === "HEAD") {
33
+ res.setHeader("cache-control", "no-store");
34
+ sendText(res, 200, "ok\n");
35
+ return;
36
+ }
37
+ const body = await readBody(req, requestController.signal);
22
38
  const requestInit = {
23
39
  method: req.method ?? "GET",
24
40
  headers: toFetchHeaders(req.headers),
41
+ signal: requestController.signal,
25
42
  };
26
43
  if (body.length)
27
44
  requestInit.body = body;
@@ -117,7 +134,12 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
117
134
  if (req.method === "DELETE") {
118
135
  await deleteFile(root, relative);
119
136
  sendText(res, 200, "File deleted successfully\n");
120
- logRequest({ method: req.method, path: url.pathname, status: 200, detail: "REST DELETE" });
137
+ logRequest({
138
+ method: req.method,
139
+ path: url.pathname,
140
+ status: 200,
141
+ detail: "REST DELETE",
142
+ });
121
143
  recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
122
144
  return;
123
145
  }
@@ -131,8 +153,16 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
131
153
  recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 404, authenticated, startedAt);
132
154
  }
133
155
  catch (error) {
156
+ if (requestController.signal.aborted || res.destroyed)
157
+ return;
134
158
  const code = error.code;
135
- const status = code === "ENOENT" ? 404 : code === "PAYLOAD_TOO_LARGE" ? 413 : 500;
159
+ const status = code === "ENOENT"
160
+ ? 404
161
+ : code === "PAYLOAD_TOO_LARGE"
162
+ ? 413
163
+ : code === "REQUEST_TIMEOUT"
164
+ ? 408
165
+ : 500;
136
166
  sendText(res, status, `${error instanceof Error ? error.message : String(error)}\n`);
137
167
  logRequest({
138
168
  method: req.method ?? "UNKNOWN",
@@ -143,6 +173,9 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
143
173
  recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, status, authenticated, startedAt);
144
174
  }
145
175
  });
176
+ server.requestTimeout = REQUEST_TIMEOUT_MS;
177
+ server.headersTimeout = HEADERS_TIMEOUT_MS;
178
+ server.keepAliveTimeout = KEEP_ALIVE_TIMEOUT_MS;
146
179
  return new Promise((resolve, reject) => {
147
180
  server.once("error", reject);
148
181
  server.listen(port, "127.0.0.1", () => resolve({
@@ -531,12 +564,39 @@ function toFetchHeaders(headers) {
531
564
  }
532
565
  return result;
533
566
  }
534
- function readBody(req) {
567
+ function readBody(req, signal) {
535
568
  return new Promise((resolve, reject) => {
536
569
  const chunks = [];
537
570
  let size = 0;
538
571
  let tooLarge = false;
539
- req.on("data", (chunk) => {
572
+ let settled = false;
573
+ const timer = setTimeout(() => {
574
+ const error = new Error(`Request body timed out after ${REQUEST_BODY_TIMEOUT_MS / 1000}s.`);
575
+ error.code = "REQUEST_TIMEOUT";
576
+ finish(error);
577
+ }, REQUEST_BODY_TIMEOUT_MS);
578
+ const cleanup = () => {
579
+ clearTimeout(timer);
580
+ req.removeListener("aborted", onAborted);
581
+ req.removeListener("error", onError);
582
+ req.removeListener("data", onData);
583
+ req.removeListener("end", onEnd);
584
+ signal.removeEventListener("abort", onSignalAbort);
585
+ };
586
+ const finish = (error, value) => {
587
+ if (settled)
588
+ return;
589
+ settled = true;
590
+ cleanup();
591
+ if (error)
592
+ reject(error);
593
+ else
594
+ resolve(value ?? Buffer.alloc(0));
595
+ };
596
+ const onAborted = () => finish(new Error("Request aborted by the client."));
597
+ const onError = (error) => finish(error);
598
+ const onSignalAbort = () => finish(new Error("Request aborted by the client."));
599
+ const onData = (chunk) => {
540
600
  if (tooLarge)
541
601
  return;
542
602
  const buffer = Buffer.from(chunk);
@@ -546,17 +606,25 @@ function readBody(req) {
546
606
  return;
547
607
  }
548
608
  chunks.push(buffer);
549
- });
550
- req.on("end", () => {
609
+ };
610
+ const onEnd = () => {
551
611
  if (tooLarge) {
552
612
  const error = new Error(`Request body exceeds the ${MAX_BODY_BYTES / 1024 / 1024} MiB limit.`);
553
613
  error.code = "PAYLOAD_TOO_LARGE";
554
- reject(error);
614
+ finish(error);
555
615
  return;
556
616
  }
557
- resolve(Buffer.concat(chunks));
558
- });
559
- req.on("error", reject);
617
+ finish(undefined, Buffer.concat(chunks));
618
+ };
619
+ if (signal.aborted) {
620
+ onSignalAbort();
621
+ return;
622
+ }
623
+ req.once("aborted", onAborted);
624
+ req.once("error", onError);
625
+ signal.addEventListener("abort", onSignalAbort, { once: true });
626
+ req.on("data", onData);
627
+ req.on("end", onEnd);
560
628
  });
561
629
  }
562
630
  function sendText(res, status, text, contentType = "text/plain; charset=utf-8") {
package/dist/src/setup.js CHANGED
@@ -104,12 +104,26 @@ export async function runFirstTimeSetup(config, defaultDirectory = ".") {
104
104
  const name = await requiredText(rl, "Profile name", "default");
105
105
  const directory = resolve(await requiredText(rl, "Directory to expose", defaultDirectory));
106
106
  const port = await requiredPort(rl, 3002);
107
- const tunnel = await choose(rl, "Tunnel", ["wormhole", "none"], "wormhole");
107
+ let hostname;
108
+ let cloudflaredToken;
108
109
  let subdomain;
109
- if (tunnel === "wormhole" &&
110
+ console.log("");
111
+ console.log(`${section("◆")} ${section(bold("Cloudflare Tunnel"))}`);
112
+ console.log(rule());
113
+ const useCloudflared = await confirm(rl, "Set up Cloudflare Tunnel?", true);
114
+ if (useCloudflared) {
115
+ hostname = await requiredHostname(rl, "Public hostname (must be routed to this tunnel)");
116
+ cloudflaredToken = await requiredSecret(rl, "Cloudflare tunnel token");
117
+ }
118
+ console.log("");
119
+ console.log(`${section("◆")} ${section(bold("Wormhole"))}`);
120
+ console.log(rule());
121
+ const useWormhole = await confirm(rl, "Set up Wormhole too?", false);
122
+ if (useWormhole &&
110
123
  (await confirm(rl, "Use a custom Wormhole subdomain? (No uses a random URL.)", false))) {
111
124
  subdomain = await requiredText(rl, "Wormhole subdomain");
112
125
  }
126
+ const tunnel = useCloudflared ? "cloudflared" : useWormhole ? "wormhole" : "none";
113
127
  const npm = await optionalList(rl, "Allowed npm scripts (comma-separated, blank for none)");
114
128
  const commands = await optionalList(rl, "Allowed commands (comma-separated, blank for none)");
115
129
  console.log("");
@@ -119,9 +133,9 @@ export async function runFirstTimeSetup(config, defaultDirectory = ".") {
119
133
  bold("Privacy first"),
120
134
  dim("Telemetry is optional and stays local on this machine."),
121
135
  dim("It helps us understand usage, failures, and reliability improvements."),
122
- dim("Standard levels never record file contents, command arguments, tokens,"),
123
- dim("environment variables, or project paths."),
124
- dim("Diagnostic mode is a separate opt-in local troubleshooting mode and records identifying metadata."),
136
+ dim("Standard levels avoid file contents, command arguments, tokens, and project paths."),
137
+ dim("Diagnostic mode records bounded raw tool inputs/outputs and identifying metadata."),
138
+ dim("It may contain source code or other sensitive project data; it stays local."),
125
139
  ]);
126
140
  console.log("");
127
141
  console.log(` ${green("●")} ${white("none")} ${dim("disabled")}`);
@@ -131,9 +145,25 @@ export async function runFirstTimeSetup(config, defaultDirectory = ".") {
131
145
  console.log(` ${cyan("●")} ${white("diagnostic")} ${dim("detailed + local paths, host/user, PID, Node version, memory/uptime")}`);
132
146
  console.log("");
133
147
  const telemetry = await choose(rl, "Telemetry level", ["none", "anonymous", "basic", "detailed", "diagnostic"], "none");
134
- const profile = { directory, port, tunnel, token: randomBytes(24).toString("hex") };
148
+ const profile = {
149
+ directory,
150
+ port,
151
+ tunnel,
152
+ defaultTunnel: tunnel,
153
+ token: randomBytes(24).toString("hex"),
154
+ };
135
155
  if (subdomain)
136
156
  profile.subdomain = subdomain;
157
+ if (hostname)
158
+ profile.hostname = hostname;
159
+ if (useCloudflared || useWormhole) {
160
+ profile.tunnels = {
161
+ ...(useWormhole ? { wormhole: { ...(subdomain ? { subdomain } : { random: true }) } } : {}),
162
+ ...(useCloudflared && hostname
163
+ ? { cloudflared: { hostname, ...(cloudflaredToken ? { token: cloudflaredToken } : {}) } }
164
+ : {}),
165
+ };
166
+ }
137
167
  if (npm.length)
138
168
  profile.npm = { allowedScripts: npm };
139
169
  if (commands.length)
@@ -168,6 +198,37 @@ async function requiredText(rl, label, defaultValue) {
168
198
  console.log(` ${error("✕")} ${dim("A value is required.")}`);
169
199
  }
170
200
  }
201
+ async function requiredSecret(rl, label) {
202
+ while (true) {
203
+ output.write(` ${bold(label)}: `);
204
+ const writableRl = rl;
205
+ const writeToOutput = writableRl._writeToOutput;
206
+ writableRl._writeToOutput = () => { };
207
+ let value;
208
+ try {
209
+ value = (await rl.question("")).trim();
210
+ }
211
+ finally {
212
+ writableRl._writeToOutput = writeToOutput;
213
+ output.write("\n");
214
+ }
215
+ if (value)
216
+ return value;
217
+ console.log(` ${error("✕")} ${dim("A value is required.")}`);
218
+ }
219
+ }
220
+ async function requiredHostname(rl, label) {
221
+ while (true) {
222
+ const value = (await rl.question(` ${bold(label)}: `)).trim().toLowerCase();
223
+ const labels = value.split(".");
224
+ const valid = value.length <= 253 &&
225
+ labels.length >= 2 &&
226
+ labels.every((part) => part.length >= 1 && part.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part));
227
+ if (valid)
228
+ return value;
229
+ console.log(` ${error("✕")} ${dim("Enter a hostname only, such as agent-dir-mcp.scriptr.uk (no spaces, https://, or path).")}`);
230
+ }
231
+ }
171
232
  async function requiredPort(rl, defaultValue) {
172
233
  while (true) {
173
234
  const value = (await rl.question(` ${bold("Port")} ${dim(`[${defaultValue}]`)}: `)).trim() ||
@@ -40,6 +40,33 @@ export function sanitizeAnonymousEvent(event) {
40
40
  return {
41
41
  event: "tool_call",
42
42
  tool: sanitizeIdentifier(event.tool, SAFE_TOOL_NAME),
43
+ ...(event.requestIdHash === undefined
44
+ ? {}
45
+ : { requestIdHash: sanitizeHash(event.requestIdHash) }),
46
+ ...(event.inputFingerprint === undefined
47
+ ? {}
48
+ : { inputFingerprint: sanitizeHash(event.inputFingerprint) }),
49
+ ...(event.outputFingerprint === undefined
50
+ ? {}
51
+ : { outputFingerprint: sanitizeHash(event.outputFingerprint) }),
52
+ ...optionalNumber("estimatedInputTokens", event.estimatedInputTokens),
53
+ ...optionalNumber("estimatedOutputTokens", event.estimatedOutputTokens),
54
+ ...optionalNumber("cumulativeOutputBytes", event.cumulativeOutputBytes),
55
+ ...optionalNumber("cumulativeEstimatedOutputTokens", event.cumulativeEstimatedOutputTokens),
56
+ ...optionalNumber("duplicateOutputBytes", event.duplicateOutputBytes),
57
+ ...optionalNumber("textOutputBytes", event.textOutputBytes),
58
+ ...optionalNumber("uniqueFileCount", event.uniqueFileCount),
59
+ ...(event.files === undefined
60
+ ? {}
61
+ : {
62
+ files: event.files.slice(0, 100).map((file) => ({
63
+ pathHash: sanitizeHash(file.pathHash),
64
+ ...(file.contentHash === undefined
65
+ ? {}
66
+ : { contentHash: sanitizeHash(file.contentHash) }),
67
+ bytes: boundedNumber(file.bytes),
68
+ })),
69
+ }),
43
70
  success: event.success === true,
44
71
  durationMs: boundedNumber(event.durationMs),
45
72
  ...optionalNumber("inputBytes", event.inputBytes),
@@ -57,6 +84,7 @@ export function sanitizeAnonymousEvent(event) {
57
84
  return {
58
85
  event: "tunnel",
59
86
  state: event.state,
87
+ ...(event.healthState === undefined ? {} : { healthState: event.healthState }),
60
88
  ...(event.attempt === undefined ? {} : { attempt: boundedInteger(event.attempt) }),
61
89
  ...(event.attempts === undefined ? {} : { attempts: boundedInteger(event.attempts) }),
62
90
  ...(event.durationMs === undefined ? {} : { durationMs: boundedNumber(event.durationMs) }),
@@ -99,6 +127,12 @@ export function createTelemetryEnvelope(level, event, runtime, project, configId
99
127
  level,
100
128
  event: sanitizeAnonymousEvent(event),
101
129
  };
130
+ if (level === "diagnostic" &&
131
+ event.event === "tool_call" &&
132
+ event.diagnosticDetails &&
133
+ envelope.event.event === "tool_call") {
134
+ envelope.event.diagnosticDetails = event.diagnosticDetails;
135
+ }
102
136
  if (level === "basic" || level === "detailed" || level === "diagnostic") {
103
137
  if (runtime)
104
138
  envelope.runtime = sanitizeRuntimeContext(runtime);
@@ -120,6 +154,16 @@ function sanitizeDiagnosticRuntimeContext(context) {
120
154
  ...(context.nodeVersion === undefined
121
155
  ? {}
122
156
  : { nodeVersion: sanitizeVersion(context.nodeVersion) }),
157
+ ...(context.distroId === undefined
158
+ ? {}
159
+ : { distroId: sanitizeSensitiveIdentifier(context.distroId) }),
160
+ ...(context.distroName === undefined ? {} : { distroName: context.distroName.slice(0, 128) }),
161
+ ...(context.distroVersion === undefined
162
+ ? {}
163
+ : { distroVersion: context.distroVersion.slice(0, 64) }),
164
+ ...(context.kernelVersion === undefined
165
+ ? {}
166
+ : { kernelVersion: context.kernelVersion.slice(0, 128) }),
123
167
  ...(context.hostname === undefined
124
168
  ? {}
125
169
  : { hostname: sanitizeSensitiveIdentifier(context.hostname) }),
@@ -201,6 +245,9 @@ function sanitizeVersion(value) {
201
245
  ? normalized
202
246
  : "unknown";
203
247
  }
248
+ function sanitizeHash(value) {
249
+ return /^[a-f0-9]{64}$/.test(value) ? value : "unknown";
250
+ }
204
251
  function sanitizeErrorCategory(value) {
205
252
  const normalized = value.trim().toLowerCase();
206
253
  return SAFE_ERROR_CATEGORIES.has(normalized) ? normalized : "unknown";
@@ -1,10 +1,9 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { appendFileSync, chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
3
+ import { dirname } from "node:path";
4
4
  import { createTelemetryEnvelope } from "./privacy.js";
5
+ import { telemetrySessionPath } from "./storage.js";
5
6
  const DEFAULT_MAX_EVENTS = 500;
6
- const DEFAULT_PERSISTED_EVENTS = 500;
7
- const TELEMETRY_FILE = join(homedir(), ".config", "agent-dir", "telemetry.jsonl");
8
7
  export class TelemetryRecorder {
9
8
  level;
10
9
  maxEvents;
@@ -21,9 +20,9 @@ export class TelemetryRecorder {
21
20
  this.runtime = options.runtime;
22
21
  this.project = options.project;
23
22
  this.persist = options.persist ?? false;
24
- this.persistPath = options.persistPath ?? TELEMETRY_FILE;
25
23
  this.configId = options.configId ?? "default";
26
- this.sessionId = options.sessionId ?? "default";
24
+ this.sessionId = options.sessionId ?? randomUUID();
25
+ this.persistPath = options.persistPath ?? telemetrySessionPath(this.configId, this.sessionId);
27
26
  if (this.persist && this.level !== "none")
28
27
  this.loadPersisted();
29
28
  }
@@ -64,8 +63,9 @@ export class TelemetryRecorder {
64
63
  .filter((event) => event &&
65
64
  event.schemaVersion === 1 &&
66
65
  event.level === this.level &&
67
- event.configId === this.configId)
68
- .slice(-Math.min(this.maxEvents, DEFAULT_PERSISTED_EVENTS));
66
+ event.configId === this.configId &&
67
+ event.sessionId === this.sessionId)
68
+ .slice(-this.maxEvents);
69
69
  this.events.push(...persisted);
70
70
  }
71
71
  catch {
@@ -80,15 +80,6 @@ export class TelemetryRecorder {
80
80
  mode: 0o600,
81
81
  });
82
82
  chmodSync(this.persistPath, 0o600);
83
- if (this.events.length === this.maxEvents) {
84
- const lines = readFileSync(this.persistPath, "utf8").split("\n").filter(Boolean);
85
- if (lines.length > this.maxEvents) {
86
- writeFileSync(this.persistPath, `${lines.slice(-this.maxEvents).join("\n")}\n`, {
87
- encoding: "utf8",
88
- mode: 0o600,
89
- });
90
- }
91
- }
92
83
  }
93
84
  catch {
94
85
  // Telemetry persistence must never affect the MCP request path.
@@ -0,0 +1,9 @@
1
+ import type { TelemetryEnvelope } from "./types.js";
2
+ export declare const TELEMETRY_DIRECTORY: string;
3
+ export declare const LEGACY_TELEMETRY_FILE: string;
4
+ export declare function telemetrySessionPath(configId: string, sessionId: string): string;
5
+ /** Migrate the old shared file once, preserving an archive and avoiding duplicate lines. */
6
+ export declare function migrateLegacyTelemetry(): void;
7
+ export declare function readAllTelemetryEvents(): TelemetryEnvelope[];
8
+ export declare function resetTelemetry(profile?: string, all?: boolean): void;
9
+ export declare function ensureTelemetryDirectory(): void;