sandoichi 0.1.6 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.1.6",
3
+ "version": "0.3.0",
4
4
  "description": "Reduce repeated tool-output context in Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "yuzushi",
package/src/hook-cli.mjs CHANGED
@@ -6,40 +6,51 @@ import { createReceipt, normalizeEvent, normalizePolicy, optimizeToolOutput } fr
6
6
  import { loadProjectRedactionProfile } from './redaction-config.mjs';
7
7
  import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
8
8
  import {
9
- closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
9
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, isDoNotTrack, readTelemetryConfig, recordActiveDay, recordFailure,
10
10
  } from './telemetry.mjs';
11
-
12
- const PLUGIN_VERSION = '0.1';
11
+ import { PLUGIN_VERSION } from './version.mjs';
13
12
 
14
13
  function todayUtc() { return new Date().toISOString().slice(0, 10); }
15
14
 
16
- /** Only counts (never content, paths, or IDs) — see docs/plans/2026-08-25-sando-telemetry-design.md.
17
- * `enforce` covers both `apply` and `dry-run`: both walk the real rewrite path, only
18
- * `observe` collects without deciding anything. */
15
+ /** Only counts (never content, paths, or IDs). */
19
16
  function recordHookTelemetry({ host, env, policy, optimization }) {
20
- const configPath = defaultTelemetryConfigPath(env);
21
- let config;
22
- try { config = readTelemetryConfig(configPath); } catch { return; }
23
- if (!config.enabled) return;
24
17
  try {
18
+ const configPath = defaultTelemetryConfigPath(env);
19
+ const config = readTelemetryConfig(configPath);
20
+ if (!config.enabled || isDoNotTrack(env)) return;
25
21
  const statePaths = defaultTelemetryStatePaths(env);
22
+ recordActiveDay({ statePaths, day: todayUtc(), pluginVersion: PLUGIN_VERSION, host });
26
23
  incrementCounter({
27
24
  statePaths,
28
25
  day: todayUtc(),
29
26
  event: 'hook_summary',
30
27
  host,
31
- mode: policy.mode === 'observe' ? 'observe' : 'enforce',
28
+ mode: policy.mode === 'apply' ? 'enforce' : policy.mode === 'dry-run' ? 'dry_run' : 'observe',
32
29
  deltas: {
33
30
  toolCalls: 1,
34
31
  redactions: optimization.stats.redactions,
35
32
  cappedOutputs: optimization.artifact ? 1 : 0,
36
33
  bytesSaved: Math.max(0, optimization.stats.inputBytes - optimization.stats.inlineBytes),
34
+ inputTokensSaved: Math.max(0, optimization.stats.estimatedInputTokens - optimization.stats.estimatedInlineTokens),
37
35
  },
38
36
  });
39
37
  closeFinishedDays({ statePaths, configPath, day: todayUtc(), pluginVersion: PLUGIN_VERSION });
40
38
  } catch { /* telemetry is best-effort and must never affect hook output */ }
41
39
  }
42
40
 
41
+ function recordHookFailure({ host, env, failureStage }) {
42
+ try {
43
+ const configPath = defaultTelemetryConfigPath(env);
44
+ const config = readTelemetryConfig(configPath);
45
+ if (!config.enabled || isDoNotTrack(env)) return;
46
+ const statePaths = defaultTelemetryStatePaths(env);
47
+ const day = todayUtc();
48
+ recordActiveDay({ statePaths, day, pluginVersion: PLUGIN_VERSION, host });
49
+ recordFailure({ statePaths, day, event: 'hook_failure_summary', host, failureStage });
50
+ closeFinishedDays({ statePaths, configPath, day, pluginVersion: PLUGIN_VERSION });
51
+ } catch { /* telemetry is best-effort and must never affect hook output */ }
52
+ }
53
+
43
54
  function hookPolicy(env, host) {
44
55
  const policy = env.SANDO_POLICY
45
56
  ? JSON.parse(env.SANDO_POLICY)
@@ -79,18 +90,23 @@ export function runHookCli({ host, env = process.env } = {}) {
79
90
  try {
80
91
  policy = hookPolicy(env, host);
81
92
  } catch (error) {
93
+ recordHookFailure({ host, env, failureStage: 'policy' });
82
94
  process.stderr.write(`sando invalid policy: ${error instanceof Error ? error.message : 'invalid input'}\n`);
83
95
  process.exitCode = 2;
84
96
  return;
85
97
  }
98
+ let failureStage = 'input';
86
99
  try {
87
100
  const input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
88
101
  const eventName = input.hook_event_name ?? input.hookEventName ?? input.event_name ?? input.eventName;
89
102
  if (eventName === 'PostToolUse') {
90
103
  const event = normalizeEvent(input);
104
+ failureStage = 'redaction';
91
105
  const redactionProfile = policy.redact ? loadProjectRedactionProfile(event.cwd).profile : undefined;
106
+ failureStage = 'optimization';
92
107
  const optimization = optimizeToolOutput({ toolName: event.toolName, toolInput: event.toolInput, output: event.output, cwd: event.cwd, policy, redactionProfile });
93
108
  let shaped;
109
+ failureStage = 'artifact';
94
110
  if (host === 'claude' && policy.mode === 'apply') {
95
111
  shaped = shapeForClaude({
96
112
  original: event.output,
@@ -101,6 +117,7 @@ export function runHookCli({ host, env = process.env } = {}) {
101
117
  policy,
102
118
  });
103
119
  }
120
+ failureStage = 'output';
104
121
  const receipt = createReceipt({ host, event, optimization, replacement: shaped });
105
122
  try {
106
123
  recordMetrics({ storagePath: defaultMetricsPath(env), host, event, optimization, receipt });
@@ -120,6 +137,7 @@ export function runHookCli({ host, env = process.env } = {}) {
120
137
  }
121
138
  }
122
139
  } catch (error) {
140
+ recordHookFailure({ host, env, failureStage });
123
141
  if (error?.code === 'SANDO_REDACTION_CONFIG') {
124
142
  process.stderr.write(`sando invalid redaction config: ${error.message}\n`);
125
143
  process.exitCode = 2;
@@ -1,6 +1,7 @@
1
1
  import readline from 'node:readline';
2
2
 
3
3
  import { optimizeToolOutput } from './core.mjs';
4
+ import { PLUGIN_VERSION } from './version.mjs';
4
5
 
5
6
  const TOOL = {
6
7
  name: 'prepare_tool_output',
@@ -19,7 +20,7 @@ function dispatch(message) {
19
20
  if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return error(message?.id, -32600, 'Invalid Request');
20
21
  if (message.id === undefined) return null;
21
22
  if (message.method === 'initialize') return response(message.id, {
22
- protocolVersion: message.params?.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'sando', version: '0.1.0' },
23
+ protocolVersion: message.params?.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'sando', version: PLUGIN_VERSION },
23
24
  });
24
25
  if (message.method === 'ping') return response(message.id, {});
25
26
  if (message.method === 'tools/list') return response(message.id, { tools: [TOOL] });
@@ -8,10 +8,10 @@ import readline from 'node:readline/promises';
8
8
  import { pathToFileURL } from 'node:url';
9
9
 
10
10
  import {
11
- CONSENT_VERSION, defaultTelemetryConfigPath, enableTelemetry, readTelemetryConfig, TELEMETRY_DETAILS_URL,
11
+ defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
12
12
  } from './telemetry.mjs';
13
13
 
14
- const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
14
+ const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/yes/N/no] `;
15
15
 
16
16
  export async function runPostinstall({
17
17
  env = process.env, stdin = process.stdin, stdout = process.stdout,
@@ -19,11 +19,12 @@ export async function runPostinstall({
19
19
  } = {}) {
20
20
  try {
21
21
  if (env.SANDO_SKIP_TELEMETRY_PROMPT) return;
22
+ if (isDoNotTrack(env)) return;
22
23
  if (!stdin.isTTY || !stdout.isTTY) return; // CI, --ignore-scripts consumers, piped installs, etc.
23
24
 
24
25
  const configPath = defaultTelemetryConfigPath(env);
25
26
  const current = readTelemetryConfig(configPath);
26
- if (current.prompted_consent_version >= CONSENT_VERSION) return; // never re-ask on reinstall/upgrade
27
+ if (current.consent_state !== 'unasked') return; // never re-ask on reinstall/upgrade
27
28
 
28
29
  const rl = readlineFactory();
29
30
  let answer;
package/src/proxy.mjs CHANGED
@@ -4,43 +4,56 @@ import { estimateTokens } from './core.mjs';
4
4
  import { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './context-transform.mjs';
5
5
  import { recordProxyRequest } from './proxy-metrics.mjs';
6
6
  import {
7
- closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
7
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, isDoNotTrack, readTelemetryConfig, recordFailure,
8
8
  } from './telemetry.mjs';
9
-
10
- const PLUGIN_VERSION = '0.1';
9
+ import { PLUGIN_VERSION } from './version.mjs';
11
10
 
12
11
  function todayUtc() { return new Date().toISOString().slice(0, 10); }
13
12
 
14
- /** Only counts (never request/response content) — see docs/plans/2026-08-25-sando-telemetry-design.md.
15
- * `host` here is the provider request shape (`anthropic`/`openai`), which does not map cleanly to
16
- * Sando's `claude`/`codex` telemetry host enum; the proxy fronts both hosts equally, so it reports
17
- * under a fixed `claude` label — this is the one place the design doc's host/provider split is
18
- * approximate, flagged for the design doc rather than silently assumed. */
19
- function recordProxyTelemetry({ env, transformed, beforeText, afterText }) {
20
- const configPath = defaultTelemetryConfigPath(env);
21
- let config;
22
- try { config = readTelemetryConfig(configPath); } catch { return; }
23
- if (!config.enabled) return;
24
- const cacheWarm = transformed.stats.cacheRewriteRatio !== null;
13
+ function telemetryProvider(provider) {
14
+ if (provider === 'anthropic') return 'anthropic';
15
+ if (provider === 'openai-chat' || provider === 'openai-responses') return 'openai';
16
+ return 'unknown';
17
+ }
18
+
19
+ /** Only counts (never request/response content). */
20
+ function recordProxyTelemetry({ env, provider, transformed, beforeText, afterText }) {
25
21
  try {
22
+ const configPath = defaultTelemetryConfigPath(env);
23
+ const config = readTelemetryConfig(configPath);
24
+ if (!config.enabled || isDoNotTrack(env)) return;
26
25
  const statePaths = defaultTelemetryStatePaths(env);
27
26
  incrementCounter({
28
27
  statePaths,
29
28
  day: todayUtc(),
30
29
  event: 'proxy_summary',
31
- host: 'claude',
30
+ provider: telemetryProvider(provider),
31
+ mode: 'enforce',
32
32
  deltas: {
33
33
  rewritesApplied: transformed.changed ? 1 : 0,
34
34
  rewritesSkippedCache: transformed.stats.cacheProtectedSkips > 0 ? 1 : 0,
35
35
  inputTokensSaved: Math.max(0, estimateTokens(beforeText) - estimateTokens(afterText)),
36
- cacheHitYes: cacheWarm ? 1 : 0,
37
- cacheHitNo: cacheWarm ? 0 : 1,
38
36
  },
39
37
  });
40
38
  closeFinishedDays({ statePaths, configPath, day: todayUtc(), pluginVersion: PLUGIN_VERSION });
41
39
  } catch { /* telemetry is best-effort and must never affect the proxied response */ }
42
40
  }
43
41
 
42
+ function recordProxyFailure({ env, provider, failureStage }) {
43
+ try {
44
+ const configPath = defaultTelemetryConfigPath(env);
45
+ const config = readTelemetryConfig(configPath);
46
+ if (!config.enabled || isDoNotTrack(env)) return;
47
+ const statePaths = defaultTelemetryStatePaths(env);
48
+ const day = todayUtc();
49
+ recordFailure({
50
+ statePaths, day, event: 'proxy_failure_summary',
51
+ provider: telemetryProvider(provider), failureStage,
52
+ });
53
+ closeFinishedDays({ statePaths, configPath, day, pluginVersion: PLUGIN_VERSION });
54
+ } catch { /* telemetry is best-effort and must never affect the proxied response */ }
55
+ }
56
+
44
57
  const DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024;
45
58
  const HOP_BY_HOP_HEADERS = new Set([
46
59
  'connection', 'content-length', 'keep-alive', 'proxy-authenticate',
@@ -176,53 +189,86 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
176
189
  let lastRequestAt = null;
177
190
 
178
191
  const server = http.createServer(async (request, outgoing) => {
192
+ let failureProvider = null;
179
193
  try {
180
194
  if (request.method === 'GET' && new URL(request.url, 'http://sando.invalid').pathname === '/health') {
181
195
  jsonResponse(outgoing, 200, { schema: 'sando-provider-proxy/v1', status: 'ok', lastStats });
182
196
  return;
183
197
  }
184
- const rawBody = await readBody(request, maxBodyBytes);
198
+ let rawBody;
199
+ try {
200
+ rawBody = await readBody(request, maxBodyBytes);
201
+ } catch (error) {
202
+ recordProxyFailure({ env, provider: null, failureStage: 'input' });
203
+ if (!outgoing.headersSent) jsonResponse(outgoing, error.message === 'request body exceeds proxy limit' ? 413 : 502, { error: 'sando proxy upstream failure' });
204
+ else outgoing.destroy();
205
+ return;
206
+ }
185
207
  let body = rawBody;
186
208
  let recordProvider = null;
187
209
  let recordModel = null;
188
210
  let recordStats = null;
189
211
  if (rawBody.length > 0 && /application\/json/i.test(request.headers['content-type'] ?? '')) {
212
+ let parsed;
190
213
  try {
191
- const parsed = JSON.parse(rawBody.toString('utf8'));
214
+ parsed = JSON.parse(rawBody.toString('utf8'));
215
+ } catch {
216
+ recordProxyFailure({ env, provider: null, failureStage: 'input' });
217
+ }
218
+ if (parsed) {
192
219
  const provider = detectProviderBody(parsed, request.headers);
193
- if (provider) {
194
- const now = Date.now();
195
- const idleMs = lastRequestAt === null ? null : now - lastRequestAt;
196
- lastRequestAt = now;
197
- const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
198
- if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
199
- recordProxyTelemetry({
200
- env, transformed,
201
- beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
202
- });
203
- lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
204
- recordProvider = provider;
205
- recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
206
- recordStats = transformed.stats;
207
- if (typeof semanticCompactor === 'function') {
208
- const candidates = listSemanticCandidates({ provider, body: transformed.body });
209
- const stats = createSemanticStats(candidates);
210
- lastStats.semantic = stats;
211
- setImmediate(() => observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }));
220
+ failureProvider = provider;
221
+ try {
222
+ if (provider) {
223
+ const now = Date.now();
224
+ const idleMs = lastRequestAt === null ? null : now - lastRequestAt;
225
+ lastRequestAt = now;
226
+ const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
227
+ if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
228
+ recordProxyTelemetry({
229
+ env, provider, transformed,
230
+ beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
231
+ });
232
+ lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
233
+ recordProvider = provider;
234
+ recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
235
+ recordStats = transformed.stats;
236
+ if (typeof semanticCompactor === 'function') {
237
+ const candidates = listSemanticCandidates({ provider, body: transformed.body });
238
+ const stats = createSemanticStats(candidates);
239
+ lastStats.semantic = stats;
240
+ setImmediate(() => observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }));
241
+ }
212
242
  }
243
+ } catch {
244
+ recordProxyFailure({ env, provider, failureStage: 'optimization' });
245
+ body = rawBody;
213
246
  }
214
- } catch {
215
- body = rawBody;
216
247
  }
217
248
  }
218
- const response = await fetch(targetUrl(upstreamUrl, request.url), {
219
- method: request.method,
220
- headers: forwardedHeaders(request),
221
- body: ['GET', 'HEAD'].includes(request.method) ? undefined : body,
222
- redirect: 'manual',
223
- });
249
+ let response;
250
+ try {
251
+ response = await fetch(targetUrl(upstreamUrl, request.url), {
252
+ method: request.method,
253
+ headers: forwardedHeaders(request),
254
+ body: ['GET', 'HEAD'].includes(request.method) ? undefined : body,
255
+ redirect: 'manual',
256
+ });
257
+ } catch {
258
+ recordProxyFailure({ env, provider: failureProvider, failureStage: 'upstream' });
259
+ if (!outgoing.headersSent) jsonResponse(outgoing, 502, { error: 'sando proxy upstream failure' });
260
+ else outgoing.destroy();
261
+ return;
262
+ }
224
263
  let responseText = '';
225
- await pipeResponse(response, outgoing, metricsPath ? (chunk) => { responseText += chunk; } : undefined);
264
+ try {
265
+ await pipeResponse(response, outgoing, metricsPath ? (chunk) => { responseText += chunk; } : undefined);
266
+ } catch {
267
+ recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
268
+ if (!outgoing.headersSent) jsonResponse(outgoing, 502, { error: 'sando proxy upstream failure' });
269
+ else outgoing.destroy();
270
+ return;
271
+ }
226
272
  if (metricsPath && recordProvider) {
227
273
  try {
228
274
  recordProxyRequest({
@@ -232,6 +278,7 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
232
278
  } catch { /* metrics are best-effort and must never affect the proxied response */ }
233
279
  }
234
280
  } catch (error) {
281
+ recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
235
282
  if (!outgoing.headersSent) jsonResponse(outgoing, error.message === 'request body exceeds proxy limit' ? 413 : 502, { error: 'sando proxy upstream failure' });
236
283
  else outgoing.destroy();
237
284
  }
@@ -1,25 +1,45 @@
1
1
  import path from 'node:path';
2
2
 
3
- import { defaultTelemetryConfigPath, readTelemetryConfig, TELEMETRY_DETAILS_URL } from './telemetry.mjs';
3
+ import {
4
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack, markTelemetryAsked,
5
+ readTelemetryConfig, TELEMETRY_DETAILS_URL,
6
+ } from './telemetry.mjs';
7
+ import { PLUGIN_VERSION } from './version.mjs';
4
8
 
5
9
  export function runSessionStart({
6
10
  env = process.env,
7
11
  stdout = process.stdout,
8
12
  rootEnv = 'PLUGIN_ROOT',
13
+ spawnImpl,
14
+ configPath,
15
+ statePaths,
9
16
  } = {}) {
10
17
  try {
11
- const config = readTelemetryConfig(defaultTelemetryConfigPath(env));
12
- if (config.prompted_consent_version > 0) {
18
+ if (isDoNotTrack(env)) {
19
+ stdout.write('{}\n');
20
+ return;
21
+ }
22
+ const telemetryConfigPath = configPath ?? defaultTelemetryConfigPath(env);
23
+ const telemetryStatePaths = statePaths ?? defaultTelemetryStatePaths(env);
24
+ const config = readTelemetryConfig(telemetryConfigPath);
25
+ if (config.enabled) {
26
+ closeFinishedDays({
27
+ statePaths: telemetryStatePaths, configPath: telemetryConfigPath, day: new Date().toISOString().slice(0, 10),
28
+ pluginVersion: PLUGIN_VERSION, ...(spawnImpl ? { spawnImpl } : {}),
29
+ });
30
+ }
31
+ if (config.consent_state !== 'unasked' || !markTelemetryAsked(telemetryConfigPath)) {
13
32
  stdout.write('{}\n');
14
33
  return;
15
34
  }
16
35
  const pluginRoot = env[rootEnv] || path.resolve(import.meta.dirname, '..');
17
36
  const cli = path.join(pluginRoot, 'lib', 'telemetry-cli.mjs');
18
37
  stdout.write(`${JSON.stringify({
38
+ systemMessage: 'Sando can send anonymous aggregate telemetry (opt-in, off by default). '
39
+ + 'Reply with exactly `sando telemetry yes` or `sando telemetry no`, '
40
+ + `or run \`node "${cli}" enable\`. Details: ${TELEMETRY_DETAILS_URL}`,
19
41
  hookSpecificOutput: {
20
42
  hookEventName: 'SessionStart',
21
- systemMessage: 'Sando can send anonymous aggregate telemetry (opt-in, off by default). '
22
- + `Run \`node "${cli}" enable\` to turn it on. Details: ${TELEMETRY_DETAILS_URL}`,
23
43
  },
24
44
  })}\n`);
25
45
  } catch {
@@ -6,11 +6,11 @@ import { pathToFileURL } from 'node:url';
6
6
 
7
7
  import {
8
8
  defaultTelemetryConfigPath, defaultTelemetryStatePaths,
9
- disableTelemetry, enableTelemetry, flushQueue, previewNextUpload, statusTelemetry, TELEMETRY_DETAILS_URL,
9
+ disableTelemetry, enableTelemetry, flushQueue, isDoNotTrack, previewNextUpload, statusTelemetry, TELEMETRY_DETAILS_URL,
10
10
  } from './telemetry.mjs';
11
11
 
12
12
  const USAGE = 'Usage: sando telemetry <status|enable|disable [--purge]|preview|flush>\n';
13
- const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
13
+ const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/yes/N/no] `;
14
14
 
15
15
  async function defaultPrompt(message) {
16
16
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -26,18 +26,34 @@ export async function runTelemetryCli({
26
26
  try {
27
27
  if (command === 'status') {
28
28
  const config = statusTelemetry(configPath);
29
+ if (isDoNotTrack(env)) {
30
+ stdout.write('telemetry: disabled by DO_NOT_TRACK\n');
31
+ return { ...config, enabled: false };
32
+ }
29
33
  stdout.write(`telemetry: ${config.enabled ? 'enabled' : 'disabled'}\n`);
30
34
  return config;
31
35
  }
32
36
  if (command === 'enable') {
37
+ if (isDoNotTrack(env)) {
38
+ stderr.write('sando telemetry: DO_NOT_TRACK is set; telemetry remains disabled\n');
39
+ return { ...statusTelemetry(configPath), enabled: false, exitCode: 1 };
40
+ }
33
41
  if (!interactive) {
34
42
  stderr.write('sando telemetry: enable requires an interactive session\n');
35
- return enableTelemetry({ configPath, interactive: false });
43
+ return { ...statusTelemetry(configPath), exitCode: 1 };
44
+ }
45
+ const current = statusTelemetry(configPath);
46
+ if (current.enabled) {
47
+ stdout.write('telemetry already enabled.\n');
48
+ return current;
49
+ }
50
+ if (current.consent_state === 'declined') {
51
+ stdout.write('telemetry not enabled.\n');
52
+ return current;
36
53
  }
37
54
  const answer = await prompt(CONSENT_PROMPT);
38
55
  const result = enableTelemetry({
39
- configPath, interactive: true,
40
- answer: /^(y|yes)$/i.test((answer ?? '').trim()) ? 'yes' : answer,
56
+ configPath, interactive: true, answer,
41
57
  });
42
58
  stdout.write(result.enabled ? 'telemetry enabled.\n' : 'telemetry not enabled.\n');
43
59
  return result;
@@ -54,6 +70,10 @@ export async function runTelemetryCli({
54
70
  return preview;
55
71
  }
56
72
  if (command === 'flush') {
73
+ if (isDoNotTrack(env)) {
74
+ stdout.write('telemetry disabled by DO_NOT_TRACK; nothing to flush.\n');
75
+ return { sent: 0 };
76
+ }
57
77
  const config = statusTelemetry(configPath);
58
78
  if (!config.enabled) {
59
79
  stdout.write('telemetry is disabled; nothing to flush.\n');
@@ -72,5 +92,6 @@ export async function runTelemetryCli({
72
92
  }
73
93
 
74
94
  if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
75
- await runTelemetryCli();
95
+ const result = await runTelemetryCli();
96
+ if (result?.exitCode) process.exitCode = result.exitCode;
76
97
  }
@@ -5,19 +5,19 @@
5
5
  import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
 
8
- import { flushQueue, readTelemetryConfig } from './telemetry.mjs';
8
+ import { flushQueue, isDoNotTrack, readTelemetryConfig } from './telemetry.mjs';
9
9
 
10
10
  function option(argv, name) {
11
11
  const index = argv.indexOf(`--${name}`);
12
12
  return index === -1 ? undefined : argv[index + 1];
13
13
  }
14
14
 
15
- export async function runTelemetryFlushEntry({ argv = process.argv.slice(2) } = {}) {
15
+ export async function runTelemetryFlushEntry({ argv = process.argv.slice(2), env = process.env } = {}) {
16
16
  const queuePath = option(argv, 'queue');
17
17
  const configPath = option(argv, 'config');
18
18
  if (!queuePath || !configPath) throw new Error('telemetry-flush-entry requires --queue and --config');
19
19
  const config = readTelemetryConfig(configPath);
20
- if (!config.enabled) return { sent: 0 };
20
+ if (!config.enabled || isDoNotTrack(env)) return { sent: 0 };
21
21
  return flushQueue({ statePaths: { queue: queuePath }, endpoint: config.endpoint });
22
22
  }
23
23
 
package/src/telemetry.mjs CHANGED
@@ -13,34 +13,54 @@ const MAX_EVENT_BYTES = 2048;
13
13
  const COUNT_BUCKETS = ['zero', 'one', '2_to_5', '6_to_20', 'gt_20'];
14
14
  const BYTE_BUCKETS = ['lt_4k', '4_to_16k', '16_to_64k', 'gte_64k'];
15
15
  const HOSTS = ['claude', 'codex'];
16
- const MODES = ['enforce', 'observe'];
17
- const YES_NO_UNKNOWN = ['yes', 'no', 'unknown'];
16
+ const PROVIDERS = ['anthropic', 'openai', 'unknown'];
17
+ const MODES = ['enforce', 'observe', 'dry_run'];
18
+ export const FAILURE_STAGES = [
19
+ 'policy', 'input', 'redaction', 'optimization', 'artifact', 'output', 'upstream', 'response',
20
+ ];
18
21
 
19
22
  const SHARED_FIELDS = {
20
23
  schema_version: (value) => value === SCHEMA_VERSION,
21
- event: (value) => value === 'hook_summary' || value === 'proxy_summary',
24
+ event: (value) => [
25
+ 'hook_summary', 'proxy_summary', 'active_day', 'hook_failure_summary', 'proxy_failure_summary',
26
+ ].includes(value),
22
27
  day_utc: (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value),
23
- plugin_version: (value) => typeof value === 'string' && /^\d+\.\d+$/.test(value) && value.length <= MAX_STRING_LENGTH,
24
- host: (value) => HOSTS.includes(value),
28
+ plugin_version: (value) => typeof value === 'string' && /^\d+\.\d+(?:\.\d+)?$/.test(value) && value.length <= MAX_STRING_LENGTH,
25
29
  };
26
30
 
27
31
  const HOOK_FIELDS = {
32
+ host: (value) => HOSTS.includes(value),
28
33
  mode: (value) => MODES.includes(value),
29
34
  tool_calls_bucket: (value) => COUNT_BUCKETS.includes(value),
30
- redactions_bucket: (value) => COUNT_BUCKETS.includes(value),
31
35
  capped_outputs_bucket: (value) => COUNT_BUCKETS.includes(value),
32
36
  bytes_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
37
+ input_tokens_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
33
38
  };
34
39
 
35
40
  const PROXY_FIELDS = {
41
+ provider: (value) => PROVIDERS.includes(value),
42
+ mode: (value) => MODES.includes(value),
36
43
  rewrites_applied_bucket: (value) => COUNT_BUCKETS.includes(value),
37
44
  rewrites_skipped_cache_bucket: (value) => COUNT_BUCKETS.includes(value),
38
45
  input_tokens_saved_bucket: (value) => BYTE_BUCKETS.includes(value),
39
- prompt_cache_hit: (value) => YES_NO_UNKNOWN.includes(value),
46
+ };
47
+
48
+ const ACTIVE_DAY_FIELDS = { host: (value) => HOSTS.includes(value) };
49
+ const HOOK_FAILURE_FIELDS = {
50
+ host: (value) => HOSTS.includes(value),
51
+ failure_stage: (value) => FAILURE_STAGES.includes(value),
52
+ };
53
+ const PROXY_FAILURE_FIELDS = {
54
+ provider: (value) => PROVIDERS.includes(value),
55
+ failure_stage: (value) => FAILURE_STAGES.includes(value),
40
56
  };
41
57
 
42
58
  function fieldsForEvent(eventType) {
43
- return eventType === 'hook_summary' ? HOOK_FIELDS : PROXY_FIELDS;
59
+ if (eventType === 'hook_summary') return HOOK_FIELDS;
60
+ if (eventType === 'proxy_summary') return PROXY_FIELDS;
61
+ if (eventType === 'active_day') return ACTIVE_DAY_FIELDS;
62
+ if (eventType === 'hook_failure_summary') return HOOK_FAILURE_FIELDS;
63
+ return PROXY_FAILURE_FIELDS;
44
64
  }
45
65
 
46
66
  export function countBucket(count) {
@@ -85,6 +105,7 @@ export function serializeEvent(payload) {
85
105
  export const TELEMETRY_CONFIG_VERSION = 1;
86
106
  export const CONSENT_VERSION = 1;
87
107
  export const TELEMETRY_DETAILS_URL = 'https://github.com/yuzushi-dev/Sando/blob/main/TELEMETRY.md';
108
+ export const CONSENT_STATES = ['unasked', 'asked', 'enabled', 'declined'];
88
109
  // Canary phase: shared backend, fronted by a Cloudflare Tunnel so it's
89
110
  // reachable from any of the owner's machines (see
90
111
  // session-handoff/deploy/telemetry/). Rate-limited at nginx (30 req/min/IP).
@@ -92,15 +113,22 @@ export const TELEMETRY_DETAILS_URL = 'https://github.com/yuzushi-dev/Sando/blob/
92
113
  // session-handoff/docs/telemetry-canary-report.md.
93
114
  export const TELEMETRY_ENDPOINT = 'https://telemetry.yuzushi.party/v1/logs';
94
115
 
116
+ export function isDoNotTrack(env = process.env) {
117
+ return env.DO_NOT_TRACK !== undefined && env.DO_NOT_TRACK !== '' && env.DO_NOT_TRACK !== '0';
118
+ }
119
+
95
120
  function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
96
121
 
97
122
  function emptyTelemetryConfig() {
98
- return { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: 0 };
123
+ return {
124
+ schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: 0, consent_state: 'unasked',
125
+ };
99
126
  }
100
127
 
101
128
  function validateTelemetryConfig(value) {
102
129
  if (!record(value) || value.schema_version !== TELEMETRY_CONFIG_VERSION || typeof value.enabled !== 'boolean'
103
- || !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0) {
130
+ || !Number.isInteger(value.prompted_consent_version) || value.prompted_consent_version < 0
131
+ || !CONSENT_STATES.includes(value.consent_state)) {
104
132
  throw new Error('telemetry config is invalid');
105
133
  }
106
134
  if (value.enabled) {
@@ -126,7 +154,14 @@ export function defaultTelemetryStatePaths(env = process.env) {
126
154
 
127
155
  export function readTelemetryConfig(configPath = defaultTelemetryConfigPath()) {
128
156
  if (!fs.existsSync(configPath)) return emptyTelemetryConfig();
129
- return validateTelemetryConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
157
+ const value = JSON.parse(fs.readFileSync(configPath, 'utf8'));
158
+ // Legacy files cannot distinguish an explicit no from blank input or the old
159
+ // y-as-no bug. Keep that ambiguous decision as asked, not as a decline.
160
+ const migrated = Object.hasOwn(value, 'consent_state') ? value : {
161
+ ...value,
162
+ consent_state: value.enabled ? 'enabled' : value.prompted_consent_version > 0 ? 'asked' : 'unasked',
163
+ };
164
+ return validateTelemetryConfig(migrated);
130
165
  }
131
166
 
132
167
  function writeTelemetryConfig(configPath, config) {
@@ -140,31 +175,66 @@ export function statusTelemetry(configPath = defaultTelemetryConfigPath()) {
140
175
  return readTelemetryConfig(configPath);
141
176
  }
142
177
 
143
- /** Only an explicit `yes` in an interactive session enables collection; anything else
144
- * (blank, `no`, EOF, or a non-interactive caller) writes the disabled prompt marker so
145
- * upgrades and reinstalls never re-prompt or silently opt a user in. */
178
+ export function normalizeConsentAnswer(answer) {
179
+ if (typeof answer !== 'string') return undefined;
180
+ const normalized = answer.trim().toLowerCase();
181
+ if (normalized === 'y' || normalized === 'yes') return 'yes';
182
+ if (normalized === 'n' || normalized === 'no') return 'no';
183
+ return undefined;
184
+ }
185
+
186
+ export function markTelemetryAsked(configPath = defaultTelemetryConfigPath()) {
187
+ ensureDirectory(path.dirname(configPath));
188
+ return withLock(`${configPath}.lock`, () => {
189
+ const current = readTelemetryConfig(configPath);
190
+ if (current.consent_state !== 'unasked') return false;
191
+ const next = {
192
+ ...current, enabled: false, prompted_consent_version: CONSENT_VERSION, consent_state: 'asked',
193
+ };
194
+ validateTelemetryConfig(next);
195
+ atomicWrite(configPath, next);
196
+ return true;
197
+ });
198
+ }
199
+
200
+ /** Only an explicit yes enables collection; explicit no declines, other input stays asked/off. */
146
201
  export function enableTelemetry({ configPath = defaultTelemetryConfigPath(), answer, interactive = true, now = () => new Date() } = {}) {
147
- if (!interactive || typeof answer !== 'string' || answer.trim().toLowerCase() !== 'yes') {
148
- return writeTelemetryConfig(configPath, { schema_version: TELEMETRY_CONFIG_VERSION, enabled: false, prompted_consent_version: CONSENT_VERSION });
202
+ if (!interactive) return { ...readTelemetryConfig(configPath), exitCode: 1 };
203
+ const normalized = normalizeConsentAnswer(answer);
204
+ if (normalized === 'yes') {
205
+ return writeTelemetryConfig(configPath, {
206
+ schema_version: TELEMETRY_CONFIG_VERSION,
207
+ enabled: true,
208
+ prompted_consent_version: CONSENT_VERSION,
209
+ consent_state: 'enabled',
210
+ consent_version: CONSENT_VERSION,
211
+ consented_at: now().toISOString(),
212
+ endpoint: TELEMETRY_ENDPOINT,
213
+ });
149
214
  }
150
215
  return writeTelemetryConfig(configPath, {
151
216
  schema_version: TELEMETRY_CONFIG_VERSION,
152
- enabled: true,
217
+ enabled: false,
153
218
  prompted_consent_version: CONSENT_VERSION,
154
- consent_version: CONSENT_VERSION,
155
- consented_at: now().toISOString(),
156
- endpoint: TELEMETRY_ENDPOINT,
219
+ consent_state: normalized === 'no' ? 'declined' : 'asked',
157
220
  });
158
221
  }
159
222
 
160
- const QUEUE_MAX_ROWS = 256;
161
- const QUEUE_MAX_BYTES = 256 * 1024;
223
+ // 30 days of daily aggregates plus activity markers for both supported hosts,
224
+ // with headroom for concurrent event dimensions and temporary outages.
225
+ const QUEUE_MAX_ROWS = 4096;
226
+ const QUEUE_MAX_BYTES = 4 * 1024 * 1024;
227
+ const ACTIVE_DAY_RETENTION_DAYS = 30;
162
228
  const DEFAULT_BATCH_MAX = 32;
229
+ const LEASE_MS = 5 * 60 * 1000;
230
+ const RETRY_DELAYS_MS = [60_000, 300_000, 1_800_000, 7_200_000, 21_600_000];
231
+ const CHILD_ENV_KEYS = ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE'];
163
232
 
164
- function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {} }; }
233
+ function emptyCounters() { return { schema_version: TELEMETRY_CONFIG_VERSION, counters: {}, active_days: {} }; }
165
234
  function readCounters(countersPath) {
166
235
  if (!fs.existsSync(countersPath)) return emptyCounters();
167
- return JSON.parse(fs.readFileSync(countersPath, 'utf8'));
236
+ const state = JSON.parse(fs.readFileSync(countersPath, 'utf8'));
237
+ return { ...state, counters: state.counters ?? {}, active_days: state.active_days ?? {} };
168
238
  }
169
239
 
170
240
  function readQueueRows(queuePath) {
@@ -181,24 +251,29 @@ function writeQueueRows(queuePath, rows) {
181
251
  fs.chmodSync(queuePath, 0o600);
182
252
  }
183
253
 
184
- /** Enforces the bounded queue (256 rows / 256 KiB), dropping the oldest rows first —
254
+ /** Enforces the bounded queue (4096 rows / 4 MiB), dropping the oldest rows first —
185
255
  * a telemetry backlog must never grow without bound or block product behavior. */
186
256
  function appendQueueRows(queuePath, newRows) {
257
+ ensureDirectory(path.dirname(queuePath));
187
258
  withLock(`${queuePath}.lock`, () => {
188
- let rows = [...readQueueRows(queuePath), ...newRows];
259
+ let rows = readQueueRows(queuePath);
260
+ const keys = new Set(rows.map((row) => queueKey(row)));
261
+ for (const row of newRows) {
262
+ if (!keys.has(queueKey(row))) { rows.push(row); keys.add(queueKey(row)); }
263
+ }
189
264
  if (rows.length > QUEUE_MAX_ROWS) rows = rows.slice(rows.length - QUEUE_MAX_ROWS);
190
265
  while (rows.length > 0 && Buffer.byteLength(rows.map((row) => JSON.stringify(row)).join('\n')) > QUEUE_MAX_BYTES) rows.shift();
191
266
  writeQueueRows(queuePath, rows);
192
267
  });
193
268
  }
194
269
 
195
- function majorityCacheHit(entry) {
196
- const yes = entry.cacheHitYes ?? 0;
197
- const no = entry.cacheHitNo ?? 0;
198
- const unknown = entry.cacheHitUnknown ?? 0;
199
- if (yes > no && yes >= unknown) return 'yes';
200
- if (no > yes && no >= unknown) return 'no';
201
- return 'unknown';
270
+ function queueKey(row) {
271
+ return [row.event, row.day_utc, row.plugin_version, row.host ?? row.provider ?? '', row.mode ?? '', row.failureStage ?? row.failure_stage ?? ''].join('|');
272
+ }
273
+
274
+ function publicRow(row) {
275
+ const allowed = { ...SHARED_FIELDS, ...fieldsForEvent(row.event) };
276
+ return Object.fromEntries(Object.entries(row).filter(([key]) => Object.hasOwn(allowed, key)));
202
277
  }
203
278
 
204
279
  function bucketEntry(entry, pluginVersion) {
@@ -207,30 +282,47 @@ function bucketEntry(entry, pluginVersion) {
207
282
  schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_summary', day_utc: entry.day, plugin_version: pluginVersion,
208
283
  host: entry.host, mode: entry.mode,
209
284
  tool_calls_bucket: countBucket(entry.toolCalls ?? 0),
210
- redactions_bucket: countBucket(entry.redactions ?? 0),
211
285
  capped_outputs_bucket: countBucket(entry.cappedOutputs ?? 0),
212
286
  bytes_saved_bucket: byteBucket(entry.bytesSaved ?? 0),
287
+ input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
213
288
  };
214
289
  }
215
- return {
290
+ if (entry.event === 'proxy_summary') return {
216
291
  schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_summary', day_utc: entry.day, plugin_version: pluginVersion,
217
- host: entry.host,
292
+ provider: entry.provider ?? 'unknown', mode: entry.mode ?? 'enforce',
218
293
  rewrites_applied_bucket: countBucket(entry.rewritesApplied ?? 0),
219
294
  rewrites_skipped_cache_bucket: countBucket(entry.rewritesSkippedCache ?? 0),
220
295
  input_tokens_saved_bucket: byteBucket(entry.inputTokensSaved ?? 0),
221
- prompt_cache_hit: majorityCacheHit(entry),
296
+ };
297
+ if (entry.event === 'hook_failure_summary') return {
298
+ schema_version: TELEMETRY_CONFIG_VERSION, event: 'hook_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
299
+ host: entry.host, failure_stage: entry.failureStage,
300
+ };
301
+ return {
302
+ schema_version: TELEMETRY_CONFIG_VERSION, event: 'proxy_failure_summary', day_utc: entry.day, plugin_version: pluginVersion,
303
+ provider: entry.provider, failure_stage: entry.failureStage,
222
304
  };
223
305
  }
224
306
 
225
307
  /** Accumulates raw per-day counts in memory/on disk; values are only bucketed (and thus
226
308
  * only ever leave the machine) once `closeDay` closes a finished UTC day. */
227
- export function incrementCounter({ statePaths, day, event, host, mode, deltas = {} }) {
228
- if (!['hook_summary', 'proxy_summary'].includes(event)) throw new Error('incrementCounter: invalid event');
229
- const key = `${day}|${event}|${host}|${mode ?? ''}`;
309
+ export function incrementCounter({ statePaths, day, event, host, provider, mode, failureStage, deltas = {} }) {
310
+ if (!['hook_summary', 'proxy_summary', 'hook_failure_summary', 'proxy_failure_summary'].includes(event)) {
311
+ throw new Error('incrementCounter: invalid event');
312
+ }
313
+ const isProxy = event.startsWith('proxy_');
314
+ const dimension = isProxy ? provider : host;
315
+ const key = event.includes('failure')
316
+ ? [day, event, dimension, failureStage ?? ''].join('|')
317
+ : [day, event, dimension, mode ?? ''].join('|');
230
318
  ensureDirectory(path.dirname(statePaths.counters));
231
319
  withLock(`${statePaths.counters}.lock`, () => {
232
320
  const state = readCounters(statePaths.counters);
233
- const existing = state.counters[key] ?? { day, event, host, mode: mode ?? null };
321
+ const existing = state.counters[key] ?? {
322
+ day, event, ...(isProxy ? { provider: dimension } : { host: dimension }),
323
+ ...(event.endsWith('_summary') && !event.includes('failure') ? { mode: mode ?? null } : {}),
324
+ ...(event.includes('failure') ? { failureStage } : {}),
325
+ };
234
326
  for (const [field, value] of Object.entries(deltas)) {
235
327
  if (!Number.isInteger(value) || value < 0) throw new Error(`incrementCounter: invalid delta ${field}`);
236
328
  existing[field] = (existing[field] ?? 0) + value;
@@ -240,6 +332,37 @@ export function incrementCounter({ statePaths, day, event, host, mode, deltas =
240
332
  });
241
333
  }
242
334
 
335
+ export function recordFailure({ statePaths, day, event, host, provider, failureStage }) {
336
+ incrementCounter({
337
+ statePaths, day, event, host, provider, failureStage, deltas: { count: 1 },
338
+ });
339
+ }
340
+
341
+ /** Queues a single non-aggregate activity marker for this UTC day and host. */
342
+ export function recordActiveDay({ statePaths, day, pluginVersion, host }) {
343
+ const marker = {
344
+ schema_version: SCHEMA_VERSION, event: 'active_day', day_utc: day, plugin_version: pluginVersion, host,
345
+ };
346
+ const validatedMarker = validateEvent(marker);
347
+ const activeDayKey = `${day}|${host}`;
348
+ ensureDirectory(path.dirname(statePaths.counters));
349
+ withLock(`${statePaths.counters}.lock`, () => {
350
+ const state = readCounters(statePaths.counters);
351
+ const activeDays = state.active_days;
352
+ const cutoff = Date.parse(`${day}T00:00:00Z`) - (ACTIVE_DAY_RETENTION_DAYS - 1) * 86_400_000;
353
+ for (const [key, recordedDay] of Object.entries(activeDays)) {
354
+ if (Date.parse(`${recordedDay}T00:00:00Z`) < cutoff) delete activeDays[key];
355
+ }
356
+ if (Object.hasOwn(activeDays, activeDayKey)) {
357
+ atomicWrite(statePaths.counters, state);
358
+ return;
359
+ }
360
+ appendQueueRows(statePaths.queue, [validatedMarker]);
361
+ activeDays[activeDayKey] = day;
362
+ atomicWrite(statePaths.counters, state);
363
+ });
364
+ }
365
+
243
366
  /** Closes a finished UTC day: buckets its raw counters into daily_aggregate rows,
244
367
  * appends them to the upload queue, and clears them from the raw counter file so a
245
368
  * day is never counted twice. */
@@ -252,11 +375,11 @@ export function closeDay({ statePaths, day, pluginVersion }) {
252
375
  if (entry.day !== day) { remaining[key] = entry; continue; }
253
376
  closedRows.push(validateEvent(bucketEntry(entry, pluginVersion)));
254
377
  }
378
+ if (closedRows.length) appendQueueRows(statePaths.queue, closedRows);
255
379
  state.counters = remaining;
256
380
  ensureDirectory(path.dirname(statePaths.counters));
257
381
  atomicWrite(statePaths.counters, state);
258
382
  });
259
- if (closedRows.length) appendQueueRows(statePaths.queue, closedRows);
260
383
  return closedRows;
261
384
  }
262
385
 
@@ -264,14 +387,16 @@ function launchDetachedFlush({ statePaths, configPath, spawnImpl }) {
264
387
  try {
265
388
  const entryPath = fileURLToPath(new URL('./telemetry-flush-entry.mjs', import.meta.url));
266
389
  const child = spawnImpl(process.execPath, [entryPath, '--queue', statePaths.queue, '--config', configPath], {
267
- detached: true, env: {}, stdio: 'ignore', windowsHide: true,
390
+ detached: true,
391
+ env: Object.fromEntries(CHILD_ENV_KEYS.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]])),
392
+ stdio: 'ignore', windowsHide: true,
268
393
  });
269
394
  child.unref();
270
395
  } catch { /* telemetry must never affect the caller */ }
271
396
  }
272
397
 
273
- /** Closes every raw counter day before `day` and starts one detached uploader.
274
- * The child receives only local state paths and an empty environment. */
398
+ /** Closes every raw counter day before `day` and starts one detached uploader
399
+ * whenever any queue row remains, including a queue from a previous session. */
275
400
  export function closeFinishedDays({
276
401
  statePaths, configPath = defaultTelemetryConfigPath(), day, pluginVersion,
277
402
  spawnImpl = spawn,
@@ -287,18 +412,44 @@ export function closeFinishedDays({
287
412
  for (const closedDay of [...days].sort()) {
288
413
  closedRows.push(...closeDay({ statePaths, day: closedDay, pluginVersion }));
289
414
  }
290
- if (closedRows.length) launchDetachedFlush({ statePaths, configPath, spawnImpl });
415
+ if (fs.existsSync(statePaths.queue) && readQueueRows(statePaths.queue).length) {
416
+ launchDetachedFlush({ statePaths, configPath, spawnImpl });
417
+ }
291
418
  return closedRows;
292
419
  }
293
420
 
294
- export function loadBatch({ statePaths, max = DEFAULT_BATCH_MAX } = {}) {
295
- return readQueueRows(statePaths.queue).slice(0, max);
421
+ function claimBatch({ statePaths, max, now = Date.now, leaseMs = LEASE_MS }) {
422
+ let claimed = [];
423
+ withLock(`${statePaths.queue}.lock`, () => {
424
+ const rows = readQueueRows(statePaths.queue);
425
+ const timestamp = now();
426
+ const available = rows.filter((row) => !row._permanent && (!row._nextAttemptAt || row._nextAttemptAt <= timestamp));
427
+ if (!available.length) return;
428
+ if (available.some((row) => row._leaseUntil > timestamp)) return;
429
+ const leaseId = `${process.pid}-${timestamp}-${Math.random().toString(36).slice(2)}`;
430
+ const selected = available.slice(0, max);
431
+ const selectedKeys = new Set(selected.map(queueKey));
432
+ for (const row of rows) {
433
+ if (selectedKeys.has(queueKey(row))) { row._leaseId = leaseId; row._leaseUntil = timestamp + leaseMs; }
434
+ }
435
+ writeQueueRows(statePaths.queue, rows);
436
+ claimed = selected.map((row) => ({ ...row, _leaseId: leaseId, _leaseUntil: timestamp + leaseMs }));
437
+ });
438
+ return claimed;
439
+ }
440
+
441
+ export function loadBatch({ statePaths, max = DEFAULT_BATCH_MAX, lease = true, now = Date.now } = {}) {
442
+ if (lease) return claimBatch({ statePaths, max, now });
443
+ return readQueueRows(statePaths.queue).filter((row) => !row._permanent && (!row._nextAttemptAt || row._nextAttemptAt <= now())).slice(0, max).map(publicRow);
296
444
  }
297
445
 
298
- export function ackBatch({ statePaths, count }) {
446
+ export function ackBatch({ statePaths, count, leaseId } = {}) {
299
447
  withLock(`${statePaths.queue}.lock`, () => {
300
448
  const rows = readQueueRows(statePaths.queue);
301
- writeQueueRows(statePaths.queue, rows.slice(count));
449
+ if (!leaseId) { writeQueueRows(statePaths.queue, rows.slice(count)); return; }
450
+ const leased = rows.filter((row) => row._leaseId === leaseId).slice(0, count);
451
+ const keys = new Set(leased.map(queueKey));
452
+ writeQueueRows(statePaths.queue, rows.filter((row) => !keys.has(queueKey(row))));
302
453
  });
303
454
  }
304
455
 
@@ -309,7 +460,7 @@ export function toOtlpLogs(rows) {
309
460
  scopeLogs: [{
310
461
  logRecords: rows.map((row) => ({
311
462
  body: { stringValue: 'sando.daily_aggregate' },
312
- attributes: Object.entries(row).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
463
+ attributes: Object.entries(publicRow(row)).map(([key, value]) => ({ key, value: { stringValue: String(value) } })),
313
464
  })),
314
465
  }],
315
466
  }],
@@ -317,30 +468,78 @@ export function toOtlpLogs(rows) {
317
468
  }
318
469
 
319
470
  export function previewNextUpload({ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX } = {}) {
320
- const rows = loadBatch({ statePaths, max });
471
+ const rows = loadBatch({ statePaths, max, lease: false });
321
472
  return { url: endpoint, headers: { 'content-type': 'application/json' }, body: toOtlpLogs(rows) };
322
473
  }
323
474
 
324
- /** Uploads at most one batch. Every failure mode (timeout, network error, non-2xx) is
325
- * swallowed and reported as `sent: 0` telemetry must never throw into, or change the
326
- * outcome of, the hook or proxy call that triggered a day close. */
327
- export async function flushQueue({ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX, timeoutMs = 3000 } = {}) {
328
- const rows = loadBatch({ statePaths, max });
329
- if (rows.length === 0) return { sent: 0 };
330
- const controller = new AbortController();
331
- const timer = setTimeout(() => controller.abort(), timeoutMs);
332
- try {
333
- const response = await fetch(endpoint, {
334
- method: 'POST', headers: { 'content-type': 'application/json' },
335
- body: JSON.stringify(toOtlpLogs(rows)), signal: controller.signal,
336
- });
337
- if (!response.ok) return { sent: 0 };
338
- ackBatch({ statePaths, count: rows.length });
339
- return { sent: rows.length };
340
- } catch {
341
- return { sent: 0 };
342
- } finally {
343
- clearTimeout(timer);
475
+ /** Drains eligible batches. Every failure mode is swallowed and reported without
476
+ * changing the outcome of the hook or proxy call that triggered the flush. */
477
+ function retryAfterMs(response, now) {
478
+ const value = response.headers?.get?.('retry-after');
479
+ if (!value) return 0;
480
+ const seconds = Number(value);
481
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
482
+ const timestamp = Date.parse(value);
483
+ return Number.isNaN(timestamp) ? 0 : Math.max(0, timestamp - now);
484
+ }
485
+
486
+ function updateClaimedRows({ statePaths, leaseId, update }) {
487
+ withLock(`${statePaths.queue}.lock`, () => {
488
+ const rows = readQueueRows(statePaths.queue);
489
+ for (const row of rows) if (row._leaseId === leaseId) update(row);
490
+ writeQueueRows(statePaths.queue, rows);
491
+ });
492
+ }
493
+
494
+ function isRetryableStatus(status) { return [429, 502, 503, 504].includes(status); }
495
+
496
+ export async function flushQueue({
497
+ statePaths, endpoint = TELEMETRY_ENDPOINT, max = DEFAULT_BATCH_MAX, timeoutMs = 3000,
498
+ fetchImpl = fetch, now = Date.now, random = Math.random, sleep = async () => {},
499
+ } = {}) {
500
+ const result = { sent: 0, rejectedLogRecords: 0 };
501
+ while (true) {
502
+ const rows = claimBatch({ statePaths, max, now });
503
+ if (rows.length === 0) return result;
504
+ const leaseId = rows[0]._leaseId;
505
+ const controller = new AbortController();
506
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
507
+ try {
508
+ const response = await fetchImpl(endpoint, {
509
+ method: 'POST', headers: { 'content-type': 'application/json' },
510
+ body: JSON.stringify(toOtlpLogs(rows)), signal: controller.signal,
511
+ });
512
+ if (response.ok) {
513
+ let body = {};
514
+ try { body = await response.json(); } catch { /* empty 2xx body */ }
515
+ result.rejectedLogRecords += Number(body?.partialSuccess?.rejectedLogRecords || 0);
516
+ ackBatch({ statePaths, count: rows.length, leaseId });
517
+ result.sent += rows.length;
518
+ } else if (isRetryableStatus(response.status)) {
519
+ updateClaimedRows({ statePaths, leaseId, update: (row) => {
520
+ const attempt = (row._attemptCount ?? 0) + 1;
521
+ const base = RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)];
522
+ row._attemptCount = attempt;
523
+ row._nextAttemptAt = now() + Math.max(base * random(), retryAfterMs(response, now()));
524
+ delete row._leaseId; delete row._leaseUntil;
525
+ }});
526
+ return result;
527
+ } else {
528
+ ackBatch({ statePaths, count: rows.length, leaseId });
529
+ }
530
+ } catch {
531
+ updateClaimedRows({ statePaths, leaseId, update: (row) => {
532
+ const attempt = (row._attemptCount ?? 0) + 1;
533
+ const base = RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)];
534
+ row._attemptCount = attempt;
535
+ row._nextAttemptAt = now() + base * random();
536
+ delete row._leaseId; delete row._leaseUntil;
537
+ }});
538
+ return result;
539
+ } finally {
540
+ clearTimeout(timer);
541
+ }
542
+ await sleep(0);
344
543
  }
345
544
  }
346
545
 
@@ -350,6 +549,7 @@ export function disableTelemetry({ configPath = defaultTelemetryConfigPath(), pu
350
549
  schema_version: TELEMETRY_CONFIG_VERSION,
351
550
  enabled: false,
352
551
  prompted_consent_version: previous.prompted_consent_version || CONSENT_VERSION,
552
+ consent_state: 'declined',
353
553
  });
354
554
  if (purge) {
355
555
  for (const target of [statePaths.counters, statePaths.queue]) fs.rmSync(target, { force: true });
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ import {
8
+ defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig,
9
+ } from './telemetry.mjs';
10
+
11
+ const CONSENT_COMMANDS = new Map([
12
+ ['sando telemetry yes', 'yes'],
13
+ ['sando telemetry no', 'no'],
14
+ ]);
15
+
16
+ function pass(stdout) { stdout.write('{}\n'); }
17
+
18
+ export function runUserPromptSubmit({
19
+ env = process.env, input, stdout = process.stdout, configPath,
20
+ } = {}) {
21
+ try {
22
+ if (isDoNotTrack(env)) {
23
+ pass(stdout);
24
+ return;
25
+ }
26
+ const telemetryConfigPath = configPath ?? defaultTelemetryConfigPath(env);
27
+ const rawInput = input === undefined ? fs.readFileSync(0, 'utf8') : input;
28
+ const prompt = JSON.parse(rawInput || '{}').prompt;
29
+ // Normalizza solo gli spazi ai bordi: non introduce ambiguita' (la stringa
30
+ // resta esatta) ed evita di perdere risposte genuine incollate con spazi.
31
+ const answer = typeof prompt === 'string' ? CONSENT_COMMANDS.get(prompt.trim()) : undefined;
32
+ if (!answer) {
33
+ pass(stdout);
34
+ return;
35
+ }
36
+ const current = readTelemetryConfig(telemetryConfigPath);
37
+ if (current.consent_state === 'declined' && answer === 'yes') {
38
+ pass(stdout);
39
+ return;
40
+ }
41
+ const result = enableTelemetry({ configPath: telemetryConfigPath, interactive: true, answer });
42
+ stdout.write(`${JSON.stringify({
43
+ systemMessage: result.enabled ? 'Sando telemetry enabled.' : 'Sando telemetry disabled.',
44
+ })}\n`);
45
+ } catch {
46
+ pass(stdout);
47
+ }
48
+ }
49
+
50
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
51
+ runUserPromptSubmit();
52
+ }
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const VERSION_PATTERN = /^\d+\.\d+(?:\.\d+)?$/;
5
+ const STANDALONE_VERSION = '0.3.0';
6
+ const METADATA_FILES = ['package.json', '.claude-plugin/plugin.json', '.codex-plugin/plugin.json'];
7
+
8
+ function findMetadataFile() {
9
+ let directory = path.resolve(import.meta.dirname, '..');
10
+ while (true) {
11
+ for (const relativePath of METADATA_FILES) {
12
+ const file = path.join(directory, relativePath);
13
+ if (fs.existsSync(file)) return file;
14
+ }
15
+ const parent = path.dirname(directory);
16
+ if (parent === directory) break;
17
+ directory = parent;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ const metadataPath = findMetadataFile();
23
+ const metadata = metadataPath ? JSON.parse(fs.readFileSync(metadataPath, 'utf8')) : { version: STANDALONE_VERSION };
24
+ if (typeof metadata.version !== 'string' || !VERSION_PATTERN.test(metadata.version)) {
25
+ throw new Error(`Invalid Sando version in ${metadataPath}`);
26
+ }
27
+
28
+ // Evaluated once per process: no repeated filesystem reads in telemetry paths.
29
+ export const PLUGIN_VERSION = metadata.version;