agent-dir 0.4.1 → 1.0.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.
@@ -4,7 +4,7 @@ export interface ServerHandle {
4
4
  }
5
5
  export declare function startServer({ root, port, token, commandConfig, telemetry, }: ServerOptions): Promise<ServerHandle>;
6
6
  export declare function validateMcpHeaders(request: Request, message: Record<string, unknown> | null): Response | undefined;
7
- export declare function getMcpLog(body: Buffer): {
7
+ export declare function getMcpLog(body: Buffer, responseBody?: unknown): {
8
8
  name: string;
9
9
  detail: string;
10
10
  } | undefined;
@@ -3,7 +3,6 @@ import { authenticate, unauthorized } from "./auth.js";
3
3
  import { logRequest } from "./logging.js";
4
4
  import { createMcpHandler } from "./mcp.js";
5
5
  import { TelemetryRecorder } from "./telemetry/recorder.js";
6
- import { deleteFile, listFiles, readFile, writeFile } from "./tools/files.js";
7
6
  const MAX_BODY_BYTES = 10 * 1024 * 1024;
8
7
  const REQUEST_BODY_TIMEOUT_MS = 30_000;
9
8
  const REQUEST_TIMEOUT_MS = 120_000;
@@ -16,7 +15,7 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
16
15
  level: telemetry.level,
17
16
  persist: telemetry.persist ?? false,
18
17
  ...(telemetry.configId ? { configId: telemetry.configId } : {}),
19
- sessionId: `http-${Date.now()}`,
18
+ sessionId: telemetry.sessionId ?? `http-${Date.now()}`,
20
19
  });
21
20
  const server = http.createServer(async (req, res) => {
22
21
  const startedAt = performance.now();
@@ -29,6 +28,11 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
29
28
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
30
29
  let authenticated = false;
31
30
  try {
31
+ if (url.pathname === HEALTH_PATH && req.method === "HEAD") {
32
+ res.setHeader("cache-control", "no-store");
33
+ sendText(res, 200, "ok\n");
34
+ return;
35
+ }
32
36
  const body = await readBody(req, requestController.signal);
33
37
  const requestInit = {
34
38
  method: req.method ?? "GET",
@@ -51,10 +55,6 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
51
55
  recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 401, authenticated, startedAt);
52
56
  return;
53
57
  }
54
- if (url.pathname === HEALTH_PATH && req.method === "HEAD") {
55
- sendText(res, 200, "ok\n");
56
- return;
57
- }
58
58
  if (url.pathname === "/mcp") {
59
59
  if (req.method !== "POST") {
60
60
  res.setHeader("allow", "POST");
@@ -82,9 +82,13 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
82
82
  return;
83
83
  }
84
84
  const response = await mcp(request);
85
+ const mcpResponseBody = await response
86
+ .clone()
87
+ .json()
88
+ .catch(() => undefined);
85
89
  const errorDetail = await getResponseErrorDetail(response);
90
+ const mcpLog = getMcpLog(body, mcpResponseBody);
86
91
  await sendResponse(res, response);
87
- const mcpLog = getMcpLog(body);
88
92
  const mcpErrorContext = response.status >= 400 ? getMcpErrorContext(body, request) : undefined;
89
93
  logRequest({
90
94
  method: req.method,
@@ -98,50 +102,6 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
98
102
  recordHttpTelemetry(httpTelemetry, req.method, url.pathname, response.status, authenticated, startedAt);
99
103
  return;
100
104
  }
101
- if (url.pathname === "/__tree" && req.method === "GET") {
102
- const tree = await listFiles(root);
103
- sendText(res, 200, JSON.stringify(tree, null, 2), "application/json; charset=utf-8");
104
- logRequest({ method: req.method, path: url.pathname, status: 200, detail: "directory" });
105
- recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
106
- return;
107
- }
108
- const relative = decodeURIComponent(url.pathname.slice(1));
109
- if (!relative) {
110
- sendText(res, 200, "agent-dir\n");
111
- logRequest({
112
- method: req.method ?? "UNKNOWN",
113
- path: url.pathname,
114
- status: 200,
115
- detail: "server",
116
- });
117
- recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 200, authenticated, startedAt);
118
- return;
119
- }
120
- if (req.method === "GET") {
121
- sendText(res, 200, await readFile(root, relative), "text/plain; charset=utf-8");
122
- logRequest({ method: req.method, path: url.pathname, status: 200, detail: "REST GET" });
123
- recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
124
- return;
125
- }
126
- if (req.method === "PUT") {
127
- await writeFile(root, relative, body.toString("utf8"));
128
- sendText(res, 200, "File updated successfully\n");
129
- logRequest({ method: req.method, path: url.pathname, status: 200, detail: "REST PUT" });
130
- recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
131
- return;
132
- }
133
- if (req.method === "DELETE") {
134
- await deleteFile(root, relative);
135
- sendText(res, 200, "File deleted successfully\n");
136
- logRequest({
137
- method: req.method,
138
- path: url.pathname,
139
- status: 200,
140
- detail: "REST DELETE",
141
- });
142
- recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
143
- return;
144
- }
145
105
  sendText(res, 404, "Not found\n");
146
106
  logRequest({
147
107
  method: req.method ?? "UNKNOWN",
@@ -323,7 +283,7 @@ async function getResponseErrorDetail(response) {
323
283
  }
324
284
  return undefined;
325
285
  }
326
- export function getMcpLog(body) {
286
+ export function getMcpLog(body, responseBody) {
327
287
  try {
328
288
  const message = JSON.parse(body.toString("utf8"));
329
289
  if (typeof message.method !== "string")
@@ -336,20 +296,7 @@ export function getMcpLog(body) {
336
296
  const name = message.params.name;
337
297
  const args = isRecordValue(message.params.arguments) ? message.params.arguments : {};
338
298
  const detail = (() => {
339
- const paths = Array.isArray(args.paths)
340
- ? args.paths.filter((value) => typeof value === "string")
341
- : [];
342
- const files = Array.isArray(args.files)
343
- ? args.files
344
- .filter((value) => typeof value === "object" && value !== null)
345
- .map((value) => value.path)
346
- .filter((value) => typeof value === "string")
347
- : [];
348
299
  switch (name) {
349
- case "list_files":
350
- return "all project files";
351
- case "list_dirs":
352
- return paths.join(", ");
353
300
  case "read_range": {
354
301
  const path = typeof args.path === "string" ? args.path : "";
355
302
  const startLine = typeof args.startLine === "number" ? args.startLine : undefined;
@@ -358,21 +305,12 @@ export function getMcpLog(body) {
358
305
  ? `${path}:${startLine}-${endLine}`
359
306
  : path;
360
307
  }
361
- case "read_files":
362
- case "delete_files":
363
- case "git_stage":
364
- case "git_unstage":
365
- return paths.join(", ");
366
- case "write_files":
367
- case "patch_files":
368
- return files.join(", ");
369
308
  case "run_npm_batch": {
370
309
  const scripts = Array.isArray(args.scripts)
371
310
  ? args.scripts.filter((value) => typeof value === "string")
372
311
  : [];
373
312
  return scripts.map((script) => `npm run ${script}`).join(", ");
374
313
  }
375
- case "search_files":
376
314
  case "search_code": {
377
315
  const query = typeof args.query === "string" ? args.query : "";
378
316
  const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
@@ -396,15 +334,6 @@ export function getMcpLog(body) {
396
334
  : `symbol: ${symbol}`
397
335
  : "";
398
336
  }
399
- case "find_definition": {
400
- const symbol = typeof args.symbol === "string" ? args.symbol : "";
401
- const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
402
- return symbol
403
- ? maxResults !== undefined
404
- ? `definition: ${symbol} (max ${maxResults})`
405
- : `definition: ${symbol}`
406
- : "";
407
- }
408
337
  case "find_references": {
409
338
  const symbol = typeof args.symbol === "string" ? args.symbol : "";
410
339
  const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
@@ -414,36 +343,36 @@ export function getMcpLog(body) {
414
343
  : `references: ${symbol}`
415
344
  : "";
416
345
  }
417
- case "git_commit":
418
- return "commit (message omitted)";
419
- case "find_imports": {
420
- const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
421
- return maxResults !== undefined ? `all imports (max ${maxResults})` : "all imports";
422
- }
423
- case "find_exports": {
424
- const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
425
- return maxResults !== undefined ? `all exports (max ${maxResults})` : "all exports";
426
- }
427
- case "diagnostics": {
428
- const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
429
- return maxResults !== undefined ? `max ${maxResults}` : "";
430
- }
431
- case "git_restore":
432
- return paths.join(", ");
433
- case "git_push":
434
- return "push (remote/branch omitted)";
435
- case "git_diff": {
436
- const staged = args.staged === true ? "staged" : "working tree";
437
- const path = typeof args.path === "string" ? args.path : "";
438
- return path ? `${staged}: ${path}` : staged;
439
- }
440
- case "git_log": {
441
- const limit = typeof args.limit === "number" ? args.limit : 20;
442
- const path = typeof args.path === "string" ? args.path : "";
443
- return path ? `last ${limit}: ${path}` : `last ${limit}`;
444
- }
445
346
  case "file_info":
446
347
  return typeof args.path === "string" ? args.path : "";
348
+ case "apply_changes": {
349
+ const response = isRecordValue(responseBody) ? responseBody : undefined;
350
+ const result = response && isRecordValue(response.result) ? response.result : undefined;
351
+ if (result?.isError === true || response?.error !== undefined)
352
+ return "change failed";
353
+ const applied = Array.isArray(result?.structuredContent) ? result.structuredContent : [];
354
+ if (applied.length) {
355
+ const entries = applied
356
+ .filter((item) => isRecordValue(item) && item.changed === true)
357
+ .map((item) => {
358
+ const kind = item.kind === "write" || item.kind === "patch" || item.kind === "delete"
359
+ ? item.kind
360
+ : "change";
361
+ const path = typeof item.path === "string" ? item.path : "(unknown path)";
362
+ return `${kind} ${path}`;
363
+ });
364
+ if (!entries.length)
365
+ return args.dryRun === true ? "preview: no file changes" : "no file changes";
366
+ const shown = entries.slice(0, 5);
367
+ const suffix = entries.length > shown.length ? ` • +${entries.length - shown.length} more` : "";
368
+ return `${args.dryRun === true ? "preview" : "changed"}: ${shown.join(" • ")}${suffix}`;
369
+ }
370
+ const changes = Array.isArray(args.changes) ? args.changes : [];
371
+ const paths = changes
372
+ .filter((item) => isRecordValue(item))
373
+ .map((item) => (typeof item.path === "string" ? item.path : "(unknown path)"));
374
+ return `requested: ${paths.slice(0, 5).join(" • ")}${paths.length > 5 ? ` • +${paths.length - 5} more` : ""}`;
375
+ }
447
376
  case "run_command_batch": {
448
377
  const commands = Array.isArray(args.commands) ? args.commands : [];
449
378
  return commands
@@ -463,33 +392,7 @@ export function getMcpLog(body) {
463
392
  }
464
393
  function getMcpMethodDetail(method, params) {
465
394
  const value = params ?? {};
466
- const capabilities = isRecordValue(value.capabilities)
467
- ? Object.keys(value.capabilities).sort()
468
- : [];
469
- const clientInfo = isRecordValue(value.clientInfo) ? value.clientInfo : undefined;
470
- const clientName = typeof clientInfo?.name === "string" ? clientInfo.name : undefined;
471
- const clientVersion = typeof clientInfo?.version === "string" ? clientInfo.version : undefined;
472
- const protocol = typeof value.protocolVersion === "string"
473
- ? value.protocolVersion
474
- : isRecordValue(value._meta) &&
475
- typeof value._meta["io.modelcontextprotocol/protocolVersion"] === "string"
476
- ? value._meta["io.modelcontextprotocol/protocolVersion"]
477
- : undefined;
478
395
  switch (method) {
479
- case "initialize": {
480
- const client = clientName
481
- ? clientVersion
482
- ? `${clientName} v${clientVersion}`
483
- : clientName
484
- : undefined;
485
- return ([
486
- protocol ? `protocol: ${protocol}` : undefined,
487
- client ? `client: ${client}` : undefined,
488
- capabilities.length ? `capabilities: ${capabilities.join(", ")}` : undefined,
489
- ]
490
- .filter((part) => Boolean(part))
491
- .join(" • ") || "initialization handshake");
492
- }
493
396
  case "notifications/initialized":
494
397
  return "initialization complete";
495
398
  case "tools/list":
@@ -534,15 +437,7 @@ function formatCommand(command, args) {
534
437
  return count ? `${command} (args: ${count})` : command;
535
438
  }
536
439
  function recordHttpTelemetry(recorder, method, pathname, status, authenticated, startedAt) {
537
- const route = pathname === "/mcp"
538
- ? "mcp"
539
- : pathname === "/__tree"
540
- ? "tree"
541
- : pathname === "/"
542
- ? "root"
543
- : pathname.startsWith("/") && pathname.length > 1
544
- ? "file"
545
- : "other";
440
+ const route = pathname === "/mcp" ? "mcp" : "other";
546
441
  recorder.record({
547
442
  event: "http_request",
548
443
  method,
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() ||
@@ -31,6 +31,8 @@ export function sanitizeAnonymousEvent(event) {
31
31
  method: sanitizeMcpMethod(event.method),
32
32
  success: event.success === true,
33
33
  durationMs: boundedNumber(event.durationMs),
34
+ ...optionalNumber("responseBytes", event.responseBytes),
35
+ ...optionalNumber("estimatedResponseTokens", event.estimatedResponseTokens),
34
36
  ...(event.errorCategory === undefined
35
37
  ? {}
36
38
  : { errorCategory: sanitizeErrorCategory(event.errorCategory) }),
@@ -40,6 +42,33 @@ export function sanitizeAnonymousEvent(event) {
40
42
  return {
41
43
  event: "tool_call",
42
44
  tool: sanitizeIdentifier(event.tool, SAFE_TOOL_NAME),
45
+ ...(event.requestIdHash === undefined
46
+ ? {}
47
+ : { requestIdHash: sanitizeHash(event.requestIdHash) }),
48
+ ...(event.inputFingerprint === undefined
49
+ ? {}
50
+ : { inputFingerprint: sanitizeHash(event.inputFingerprint) }),
51
+ ...(event.outputFingerprint === undefined
52
+ ? {}
53
+ : { outputFingerprint: sanitizeHash(event.outputFingerprint) }),
54
+ ...optionalNumber("estimatedInputTokens", event.estimatedInputTokens),
55
+ ...optionalNumber("estimatedOutputTokens", event.estimatedOutputTokens),
56
+ ...optionalNumber("cumulativeOutputBytes", event.cumulativeOutputBytes),
57
+ ...optionalNumber("cumulativeEstimatedOutputTokens", event.cumulativeEstimatedOutputTokens),
58
+ ...optionalNumber("duplicateOutputBytes", event.duplicateOutputBytes),
59
+ ...optionalNumber("textOutputBytes", event.textOutputBytes),
60
+ ...optionalNumber("uniqueFileCount", event.uniqueFileCount),
61
+ ...(event.files === undefined
62
+ ? {}
63
+ : {
64
+ files: event.files.slice(0, 100).map((file) => ({
65
+ pathHash: sanitizeHash(file.pathHash),
66
+ ...(file.contentHash === undefined
67
+ ? {}
68
+ : { contentHash: sanitizeHash(file.contentHash) }),
69
+ bytes: boundedNumber(file.bytes),
70
+ })),
71
+ }),
43
72
  success: event.success === true,
44
73
  durationMs: boundedNumber(event.durationMs),
45
74
  ...optionalNumber("inputBytes", event.inputBytes),
@@ -100,6 +129,12 @@ export function createTelemetryEnvelope(level, event, runtime, project, configId
100
129
  level,
101
130
  event: sanitizeAnonymousEvent(event),
102
131
  };
132
+ if (level === "diagnostic" &&
133
+ event.event === "tool_call" &&
134
+ event.diagnosticDetails &&
135
+ envelope.event.event === "tool_call") {
136
+ envelope.event.diagnosticDetails = event.diagnosticDetails;
137
+ }
103
138
  if (level === "basic" || level === "detailed" || level === "diagnostic") {
104
139
  if (runtime)
105
140
  envelope.runtime = sanitizeRuntimeContext(runtime);
@@ -121,6 +156,16 @@ function sanitizeDiagnosticRuntimeContext(context) {
121
156
  ...(context.nodeVersion === undefined
122
157
  ? {}
123
158
  : { nodeVersion: sanitizeVersion(context.nodeVersion) }),
159
+ ...(context.distroId === undefined
160
+ ? {}
161
+ : { distroId: sanitizeSensitiveIdentifier(context.distroId) }),
162
+ ...(context.distroName === undefined ? {} : { distroName: context.distroName.slice(0, 128) }),
163
+ ...(context.distroVersion === undefined
164
+ ? {}
165
+ : { distroVersion: context.distroVersion.slice(0, 64) }),
166
+ ...(context.kernelVersion === undefined
167
+ ? {}
168
+ : { kernelVersion: context.kernelVersion.slice(0, 128) }),
124
169
  ...(context.hostname === undefined
125
170
  ? {}
126
171
  : { hostname: sanitizeSensitiveIdentifier(context.hostname) }),
@@ -202,6 +247,9 @@ function sanitizeVersion(value) {
202
247
  ? normalized
203
248
  : "unknown";
204
249
  }
250
+ function sanitizeHash(value) {
251
+ return /^[a-f0-9]{64}$/.test(value) ? value : "unknown";
252
+ }
205
253
  function sanitizeErrorCategory(value) {
206
254
  const normalized = value.trim().toLowerCase();
207
255
  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;