zas-agent 0.5.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-03
11
+
12
+ ### Added
13
+
14
+ - Usage reporting, so a defect like 0.5.0's — a release that could not
15
+ receive a file at all — is visible without somebody writing in. One report
16
+ after each tool call: the tool, whether it worked, the closed error code
17
+ when it did not, a duration bucket and the version. Never a file name, a
18
+ path, a channel name, a message or a stack. It goes to Zas, not to an
19
+ analytics service: this package holds no analytics token and opens no
20
+ connection to a third party.
21
+ - `zas-agent telemetry [on|off]`, `ZAS_AGENT_TELEMETRY` and `DO_NOT_TRACK`.
22
+ Reporting is on by default; `pair` prints what it collects before it opens
23
+ a browser, the first `serve` on a machine prints it once, and `zas_status`
24
+ always says which way it is set. The choice lives in
25
+ `~/.zas/agent/settings.json` and survives pairing again.
26
+
10
27
  ## [0.5.1] - 2026-09-03
11
28
 
12
29
  ### Fixed
package/README.md CHANGED
@@ -252,6 +252,36 @@ Two things worth knowing before you point a model at your account:
252
252
  Everything those tools touch lands inside your own account and your own
253
253
  machine. Revoking the agent stops all of them.
254
254
 
255
+ ## What it sends back
256
+
257
+ This package reports how it is used, so defects like a release that could not
258
+ receive a file at all stop being invisible. It is on by default and it is
259
+ yours to turn off:
260
+
261
+ ```bash
262
+ npx -y zas-agent telemetry off # and `on` again, and `telemetry` to see
263
+ ```
264
+
265
+ `ZAS_AGENT_TELEMETRY=0` and the cross-vendor `DO_NOT_TRACK=1` do the same
266
+ without writing anything. The choice lives in `~/.zas/agent/settings.json` and
267
+ covers every profile on the machine; pairing again does not reset it.
268
+ `zas_status` always says which way it is set.
269
+
270
+ What one report carries, and nothing else: the tool that ran, whether it
271
+ worked, the closed error code when it did not, which of four duration buckets
272
+ it fell in, and the package version. No file name, no file contents, no path,
273
+ no channel name, no error message, no stack.
274
+
275
+ Where it goes: to Zas, never to an analytics service directly. This package
276
+ holds no analytics token and opens no connection to a third party, so no
277
+ address of yours reaches one. The report is attributed to the Zas account the
278
+ agent is paired with, which the server reads from the session — the request
279
+ carries no identifier of its own. Nothing is reported before pairing, because
280
+ until then there is no account it could belong to.
281
+
282
+ A report is never allowed to matter: it is sent after the answer, waited on
283
+ for at most five seconds, tried once, and dropped in silence if it fails.
284
+
255
285
  ## Data on disk
256
286
 
257
287
  One directory per profile, so one machine can hold a Claude Code agent and a
@@ -262,8 +292,8 @@ Codex agent side by side without either reading the other's keys:
262
292
  | macOS, Linux | `~/.zas/agent/<profile>/` |
263
293
  | Windows | `%USERPROFILE%\.zas\agent\<profile>\` |
264
294
 
265
- Four files, all written through a temporary file and renamed into place, so a
266
- crash mid-write cannot leave half a file behind:
295
+ Four files in the profile directory, all written through a temporary file and
296
+ renamed into place, so a crash mid-write cannot leave half a file behind:
267
297
 
268
298
  - `identity.json` — the agent uid, the owner uid, the name, and the two key
269
299
  pairs. Back it up like a private key, or delete it and pair again.
@@ -276,6 +306,12 @@ crash mid-write cannot leave half a file behind:
276
306
  ten minutes, so a retried tool call answers without touching the network. It
277
307
  stores hashes, never a title or a note's first line. Disposable.
278
308
 
309
+ One more file sits next to the profiles, in `~/.zas/agent/`:
310
+
311
+ - `settings.json` — whether this machine reports usage, and when the notice
312
+ above was last printed. It belongs to the machine, not to a profile, so one
313
+ decision covers every agent on it.
314
+
279
315
  On macOS and Linux the directory is created `0700` and every file `0600`. On
280
316
  Windows those bits have no effect: the files carry the permissions of the user
281
317
  profile they live in, and the package does not try to set any others.
@@ -306,6 +342,8 @@ agent: same keys, same channels, one row.
306
342
  | --- | --- | --- |
307
343
  | `--profile <name>` | `claude-code` | Which identity directory this process uses. Letters, digits, `.`, `_` and `-`, up to 64, and it may not start with a dot. |
308
344
  | `ZAS_AGENT_HOME` | `~/.zas/agent` | Where the profile directories live. |
345
+ | `ZAS_AGENT_TELEMETRY` | unset | `0` turns usage reporting off for this process, `1` on. It outranks the stored choice. |
346
+ | `DO_NOT_TRACK` | unset | `1` turns usage reporting off. Read only to turn it off. |
309
347
  | `ZAS_WEB_BASE` | `https://zas.red` | The web app the pairing URL points at. |
310
348
  | `ZAS_API_BASE` | `https://zas.red/api` | The API. |
311
349
  | `ZAS_TOKEN_BASE` | `https://zas.red/anon-token` | The challenge and token routes. |
@@ -313,7 +351,8 @@ agent: same keys, same channels, one row.
313
351
  | `ZAS_FIREBASE_PROJECT` | `zas-me` | The project whose Firestore the read path queries. |
314
352
  | `ZAS_FIREBASE_API_KEY` | the public web key | The key used to exchange a custom token for a session. |
315
353
 
316
- Only `--profile` and `ZAS_AGENT_HOME` are worth setting by hand. The rest exist
354
+ Only `--profile`, `ZAS_AGENT_HOME` and the two telemetry switches are worth
355
+ setting by hand. The rest exist
317
356
  so the package can be pointed at a test deployment.
318
357
 
319
358
  ## Development
package/dist/cli.js CHANGED
@@ -211,6 +211,7 @@ var IDENTITY_FILE = "identity.json";
211
211
  var PENDING_FILE = "pending.json";
212
212
  var GRANTS_FILE = "grants.json";
213
213
  var FINGERPRINTS_FILE = "fingerprints.json";
214
+ var SETTINGS_FILE = "settings.json";
214
215
  function agentHome() {
215
216
  return process.env.ZAS_AGENT_HOME || join(homedir(), ".zas", "agent");
216
217
  }
@@ -300,6 +301,13 @@ function loadFingerprints(profile) {
300
301
  function saveFingerprints(profile, cache) {
301
302
  writePrivate(profileDir(profile), FINGERPRINTS_FILE, cache);
302
303
  }
304
+ function loadSettings() {
305
+ const stored = readCache(agentHome(), SETTINGS_FILE);
306
+ return isPlainObject(stored) ? stored : null;
307
+ }
308
+ function saveSettings(settings) {
309
+ writePrivate(agentHome(), SETTINGS_FILE, settings);
310
+ }
303
311
  function defaultEndpoints() {
304
312
  return {
305
313
  api_base: process.env.ZAS_API_BASE || "https://zas.red/api",
@@ -1636,15 +1644,15 @@ async function uploadFallback(options) {
1636
1644
  const key = await aesKey(meta);
1637
1645
  const completed = [];
1638
1646
  const partProgress = /* @__PURE__ */ new Map();
1639
- const report = () => options.onProgress?.(
1647
+ const report2 = () => options.onProgress?.(
1640
1648
  Math.min(meta.cipher_size, [...partProgress.values()].reduce((sum, value) => sum + value, 0)),
1641
1649
  meta.cipher_size
1642
1650
  );
1643
1651
  for (let first = 1; first <= meta.part_count; first += URL_BATCH) {
1644
1652
  aborted(signal);
1645
- const count = Math.min(URL_BATCH, meta.part_count - first + 1);
1646
- const signed = await options.getUrls(first, count);
1647
- if (signed.length !== count || signed.some((entry, i) => entry.part_number !== first + i || typeof entry.url !== "string")) throw new Error("bad_fallback_urls");
1653
+ const count2 = Math.min(URL_BATCH, meta.part_count - first + 1);
1654
+ const signed = await options.getUrls(first, count2);
1655
+ if (signed.length !== count2 || signed.some((entry, i) => entry.part_number !== first + i || typeof entry.url !== "string")) throw new Error("bad_fallback_urls");
1648
1656
  let cursor = 0;
1649
1657
  const workers = Array.from({ length: Math.min(CONCURRENCY, signed.length) }, async () => {
1650
1658
  for (; ; ) {
@@ -1661,11 +1669,11 @@ async function uploadFallback(options) {
1661
1669
  for (let attempt = 0; attempt < RETRIES; attempt++) {
1662
1670
  aborted(signal);
1663
1671
  partProgress.set(partNumber, 0);
1664
- report();
1672
+ report2();
1665
1673
  try {
1666
1674
  etag = await options.put(entry.url, cipher, signal, (loaded) => {
1667
1675
  partProgress.set(partNumber, loaded);
1668
- report();
1676
+ report2();
1669
1677
  });
1670
1678
  break;
1671
1679
  } catch (error) {
@@ -2472,7 +2480,7 @@ function sealer(key, keyVersion) {
2472
2480
  open: (enc) => JSON.parse(new TextDecoder().decode(openRaw(key, b64ToBytes(enc))))
2473
2481
  };
2474
2482
  }
2475
- async function sendDirect(ctx, input, report, deps = {}) {
2483
+ async function sendDirect(ctx, input, report2, deps = {}) {
2476
2484
  const now = deps.now ?? (() => Date.now());
2477
2485
  const sleep2 = deps.sleep ?? defaultSleep2;
2478
2486
  const grant = await directGrantFor(ctx, input.channel);
@@ -2489,7 +2497,7 @@ async function sendDirect(ctx, input, report, deps = {}) {
2489
2497
  const { seal, open } = sealer(key, grant.key_version);
2490
2498
  const stamp = { device, owner_uid: owner };
2491
2499
  const startedAt = now();
2492
- report("offer");
2500
+ report2("offer");
2493
2501
  const meta = { name, size: file.size, mime: mimeFor(input.path) };
2494
2502
  const { id } = await ctx.client.api("POST", `/direct/${cid}`, {
2495
2503
  meta_enc: seal(meta),
@@ -2523,7 +2531,7 @@ async function sendDirect(ctx, input, report, deps = {}) {
2523
2531
  }
2524
2532
  };
2525
2533
  const turn = await fetchIce();
2526
- report("connecting");
2534
+ report2("connecting");
2527
2535
  let resolveOutcome;
2528
2536
  const outcome = new Promise((resolve2) => {
2529
2537
  resolveOutcome = resolve2;
@@ -2540,7 +2548,7 @@ async function sendDirect(ctx, input, report, deps = {}) {
2540
2548
  send: (msg) => ctx.client.api("POST", `${route}/signal`, { payload_enc: seal(msg), for: "receiver", ...stamp }).then(() => void 0),
2541
2549
  onPhase: (phase2) => {
2542
2550
  if (phase2 === "flight") {
2543
- report("flight");
2551
+ report2("flight");
2544
2552
  heartbeat ??= setInterval(() => {
2545
2553
  void ctx.client.api("POST", `${route}/heartbeat`, stamp).catch(() => void 0);
2546
2554
  }, deps.heartbeatMs ?? HEARTBEAT_MS);
@@ -2580,7 +2588,7 @@ async function sendDirect(ctx, input, report, deps = {}) {
2580
2588
  over = true;
2581
2589
  clearInterval(heartbeat);
2582
2590
  await pump;
2583
- report("finishing");
2591
+ report2("finishing");
2584
2592
  if (phase === "done") {
2585
2593
  await setState("done");
2586
2594
  return {
@@ -2619,7 +2627,7 @@ var fetchPut = async (url, bytes, signal, onLoaded) => {
2619
2627
  onLoaded(bytes.length);
2620
2628
  return etag;
2621
2629
  };
2622
- async function sendDirectFallback(ctx, record, report, deps = {}) {
2630
+ async function sendDirectFallback(ctx, record, report2, deps = {}) {
2623
2631
  const now = deps.now ?? (() => Date.now());
2624
2632
  const startedAt = now();
2625
2633
  const opened = await (deps.openFile ?? openAsBlob)(record.path).catch(() => {
@@ -2630,7 +2638,7 @@ async function sendDirectFallback(ctx, record, report, deps = {}) {
2630
2638
  const { seal } = sealer(record.key, record.key_version);
2631
2639
  const stamp = { device: record.device, owner_uid: record.owner_uid };
2632
2640
  const base = `/direct/${record.channel_id}/${record.offer_id}/fallback`;
2633
- report("encrypting");
2641
+ report2("encrypting");
2634
2642
  const meta = createFallbackMeta(file);
2635
2643
  await ctx.client.api("POST", `${base}/start`, {
2636
2644
  meta_enc: seal(meta),
@@ -2639,7 +2647,7 @@ async function sendDirectFallback(ctx, record, report, deps = {}) {
2639
2647
  part_count: meta.part_count,
2640
2648
  ...stamp
2641
2649
  });
2642
- report("uploading");
2650
+ report2("uploading");
2643
2651
  let parts;
2644
2652
  try {
2645
2653
  parts = await uploadFallback({
@@ -2647,11 +2655,11 @@ async function sendDirectFallback(ctx, record, report, deps = {}) {
2647
2655
  offerId: record.offer_id,
2648
2656
  meta,
2649
2657
  put: deps.put ?? fetchPut,
2650
- getUrls: async (from, count) => {
2658
+ getUrls: async (from, count2) => {
2651
2659
  const r = await ctx.client.api(
2652
2660
  "POST",
2653
2661
  `${base}/parts`,
2654
- { from, count, ...stamp }
2662
+ { from, count: count2, ...stamp }
2655
2663
  );
2656
2664
  return r.urls;
2657
2665
  }
@@ -2661,7 +2669,7 @@ async function sendDirectFallback(ctx, record, report, deps = {}) {
2661
2669
  if (error instanceof ZasError) throw error;
2662
2670
  throw new ZasError("upload_failed", 0, error instanceof Error ? error.message : String(error));
2663
2671
  }
2664
- report("finishing");
2672
+ report2("finishing");
2665
2673
  await ctx.client.api("POST", `${base}/complete`, {
2666
2674
  parts: parts.map((part) => ({ part_number: part.partNumber, etag: part.etag })),
2667
2675
  ...stamp
@@ -3047,7 +3055,7 @@ var DiskSink = class {
3047
3055
  return error;
3048
3056
  }
3049
3057
  };
3050
- async function receiveDirect(ctx, input, report, deps = {}) {
3058
+ async function receiveDirect(ctx, input, report2, deps = {}) {
3051
3059
  const now = deps.now ?? (() => Date.now());
3052
3060
  const sleep2 = deps.sleep ?? defaultSleep2;
3053
3061
  const grant = await receiveGrantFor(ctx, input.channel);
@@ -3060,7 +3068,7 @@ async function receiveDirect(ctx, input, report, deps = {}) {
3060
3068
  const stamp = { device, owner_uid: owner };
3061
3069
  const startedAt = now();
3062
3070
  const channelPath = `accounts/${owner}/channels/${cid}`;
3063
- report("waiting");
3071
+ report2("waiting");
3064
3072
  const waitUntil = startedAt + (deps.offerWaitMs ?? OFFER_WAIT_MS2);
3065
3073
  let id;
3066
3074
  let meta;
@@ -3112,7 +3120,7 @@ async function receiveDirect(ctx, input, report, deps = {}) {
3112
3120
  }
3113
3121
  };
3114
3122
  const turn = await fetchIce();
3115
- report("connecting");
3123
+ report2("connecting");
3116
3124
  const sink = new DiskSink(input.dest, offerId);
3117
3125
  let resolveOutcome;
3118
3126
  const outcome = new Promise((resolve2) => {
@@ -3128,7 +3136,7 @@ async function receiveDirect(ctx, input, report, deps = {}) {
3128
3136
  expectedMeta: offered,
3129
3137
  send: (msg) => ctx.client.api("POST", `${route}/signal`, { payload_enc: seal(msg), for: "sender", ...stamp }).then(() => void 0),
3130
3138
  onPhase: (phase2) => {
3131
- if (phase2 === "flight") report("flight");
3139
+ if (phase2 === "flight") report2("flight");
3132
3140
  if (phase2 === "done" || phase2 === "failed") resolveOutcome(phase2);
3133
3141
  },
3134
3142
  onPath: (p) => {
@@ -3167,7 +3175,7 @@ async function receiveDirect(ctx, input, report, deps = {}) {
3167
3175
  const phase = await outcome;
3168
3176
  over = true;
3169
3177
  await pump;
3170
- report("finishing");
3178
+ report2("finishing");
3171
3179
  if (phase === "done") {
3172
3180
  await setState("done");
3173
3181
  return {
@@ -3202,7 +3210,7 @@ async function receiveDirect(ctx, input, report, deps = {}) {
3202
3210
  });
3203
3211
  throw new ZasError("direct_failed", 0, reason);
3204
3212
  }
3205
- async function receiveDirectFallback(ctx, record, report, deps = {}) {
3213
+ async function receiveDirectFallback(ctx, record, report2, deps = {}) {
3206
3214
  const now = deps.now ?? (() => Date.now());
3207
3215
  const startedAt = now();
3208
3216
  const { open } = sealer(record.key, record.key_version);
@@ -3224,7 +3232,7 @@ async function receiveDirectFallback(ctx, record, report, deps = {}) {
3224
3232
  throw new ZasError("file_changed", 0);
3225
3233
  }
3226
3234
  const sink = new DiskSink(record.dest, record.offer_id);
3227
- report("downloading");
3235
+ report2("downloading");
3228
3236
  try {
3229
3237
  sink.open(meta);
3230
3238
  await downloadFallback({
@@ -3240,7 +3248,7 @@ async function receiveDirectFallback(ctx, record, report, deps = {}) {
3240
3248
  if (error instanceof ZasError) throw error;
3241
3249
  throw new ZasError("network", 0, error instanceof Error ? error.message : String(error));
3242
3250
  }
3243
- report("finishing");
3251
+ report2("finishing");
3244
3252
  await ctx.client.api("POST", `${base}/received`, stamp);
3245
3253
  return {
3246
3254
  offer_id: record.offer_id,
@@ -3281,10 +3289,10 @@ var JobRunner = class {
3281
3289
  };
3282
3290
  this.jobs.unshift(job);
3283
3291
  this.jobs.length = Math.min(this.jobs.length, HISTORY);
3284
- const report = (phase) => {
3292
+ const report2 = (phase) => {
3285
3293
  if (job.status === "running") job.phase = phase;
3286
3294
  };
3287
- this.settled.set(job, work(report).then(
3295
+ this.settled.set(job, work(report2).then(
3288
3296
  (result) => {
3289
3297
  job.status = "done";
3290
3298
  job.result = result;
@@ -3328,9 +3336,477 @@ var JobRunner = class {
3328
3336
  }
3329
3337
  };
3330
3338
 
3339
+ // src/shared/product-analytics.ts
3340
+ var PRODUCT_EVENTS = [
3341
+ "account_created",
3342
+ "item.created",
3343
+ "item.downloaded",
3344
+ "item.copied",
3345
+ "item.shared",
3346
+ "item.pinned",
3347
+ "item.unpinned",
3348
+ "item.deleted",
3349
+ "item.departed",
3350
+ "channel_shared",
3351
+ "channel_mode_set",
3352
+ "invite_created",
3353
+ "invite_link_copied",
3354
+ "invite_qr_shown",
3355
+ "invite_system_sheet_opened",
3356
+ "invite_joined",
3357
+ "invite_converted",
3358
+ "channel.invite_preview",
3359
+ "channel.invite_accept",
3360
+ "channel.invite_signin",
3361
+ "enterprise.invite_preview",
3362
+ "enterprise.invite_identity",
3363
+ "enterprise.invite_signin",
3364
+ "enterprise.invite_account_switch",
3365
+ "enterprise.invite_attach",
3366
+ "enterprise.invite_accept",
3367
+ "enterprise.invite_admin",
3368
+ "enterprise.workspace_opened",
3369
+ "enterprise.workspace_switched",
3370
+ "enterprise.channel_created",
3371
+ "enterprise.channel_members_added",
3372
+ "share_link_copied",
3373
+ "share_qr_shown",
3374
+ "share_system_sheet_opened",
3375
+ "share_opened",
3376
+ "share_content_copied",
3377
+ "share_downloaded",
3378
+ "share_signin_click",
3379
+ "share.saved",
3380
+ "share_bar_on",
3381
+ "share_bar_off",
3382
+ "upload.phase_timing",
3383
+ "auth.signin_started",
3384
+ "auth.signin_result",
3385
+ "auth.session_started",
3386
+ "auth.signed_out",
3387
+ "auth.session_lost",
3388
+ "auth.token_refresh_failed",
3389
+ "account_switch.started",
3390
+ "account_switch.completed",
3391
+ "account_switch.failed",
3392
+ "bootstrap.result",
3393
+ "direct_mode_set",
3394
+ "direct.offered",
3395
+ "direct.claimed",
3396
+ "direct.connected",
3397
+ "direct.completed",
3398
+ "direct.received",
3399
+ "direct.failed",
3400
+ "direct.fallback_started",
3401
+ "direct.fallback_ready",
3402
+ "direct.fallback_received",
3403
+ "direct.fallback_failed",
3404
+ "direct.fallback_aborted",
3405
+ "direct.too_big",
3406
+ "agent.pair_opened",
3407
+ "agent.pair_approved",
3408
+ "agent.pair_refused",
3409
+ "agent.paired",
3410
+ "agent.revoked",
3411
+ "agent.send",
3412
+ "agent.tool_call"
3413
+ ];
3414
+ var SERVER_OWNED_PRODUCT_EVENTS = [
3415
+ "account_created",
3416
+ "item.departed",
3417
+ "invite_created",
3418
+ "invite_joined",
3419
+ "invite_converted",
3420
+ "share_opened",
3421
+ "share_content_copied",
3422
+ "share_downloaded",
3423
+ "share_signin_click",
3424
+ "agent.send",
3425
+ "agent.tool_call"
3426
+ ];
3427
+ var SERVER_OWNED_PRODUCT_EVENT_SET = new Set(SERVER_OWNED_PRODUCT_EVENTS);
3428
+ var CLIENT_PRODUCT_EVENTS = PRODUCT_EVENTS.filter(
3429
+ (event) => !SERVER_OWNED_PRODUCT_EVENT_SET.has(event)
3430
+ );
3431
+ var bool = { type: "boolean" };
3432
+ var count = { type: "number", min: 0 };
3433
+ var oneOf = (...values) => ({ type: "string", values: new Set(values) });
3434
+ var sizeBucket = oneOf("lt_1mb", "1_10mb", "10_100mb", "100_512mb", "512mb_5gb", "gt_5gb");
3435
+ var itemKind = oneOf("file", "text");
3436
+ var itemKindOrUnknown = oneOf("file", "text", "unknown");
3437
+ var role = oneOf("sender", "receiver");
3438
+ var candidateType = oneOf("", "host", "srflx", "relay", "prflx", "other");
3439
+ var enterpriseRole = oneOf("owner", "admin", "billing_admin", "member", "guest");
3440
+ var agentKind = oneOf("claude_code", "codex", "other");
3441
+ var pairHandoff = oneOf("loopback", "manual", "unknown");
3442
+ var AGENT_TOOL_NAMES = [
3443
+ "status",
3444
+ "pair",
3445
+ "send_file",
3446
+ "send_note",
3447
+ "send_direct",
3448
+ "send_direct_fallback",
3449
+ "receive_direct",
3450
+ "receive_direct_fallback",
3451
+ "list_items",
3452
+ "get_item",
3453
+ "jobs"
3454
+ ];
3455
+ var AGENT_ERROR_CODES = [
3456
+ "none",
3457
+ "other",
3458
+ "identity_corrupt",
3459
+ "not_paired",
3460
+ "internal",
3461
+ "agent_forbidden",
3462
+ "agent_revoked",
3463
+ "grant_missing",
3464
+ "not_found",
3465
+ "invalid_cap",
3466
+ "write_failed",
3467
+ "send_forbidden",
3468
+ "read_forbidden",
3469
+ "direct_mode",
3470
+ "not_direct_mode",
3471
+ "not_claimed",
3472
+ "no_offer",
3473
+ "offer_taken",
3474
+ "direct_cancelled",
3475
+ "direct_failed",
3476
+ "direct_not_failed",
3477
+ "file_changed",
3478
+ "webrtc_unavailable",
3479
+ "fallback_unavailable",
3480
+ "key_stale",
3481
+ "quota_exceeded",
3482
+ "rate_limited",
3483
+ "file_too_big",
3484
+ "duplicate",
3485
+ "pairing_expired",
3486
+ "pairing_cancelled",
3487
+ "claim_mismatch",
3488
+ "pairing_not_approved",
3489
+ "pairing_claimed",
3490
+ "agent_limit",
3491
+ "grant_limit",
3492
+ "feature_disabled",
3493
+ "upload_failed",
3494
+ "oprf_failed",
3495
+ "network",
3496
+ "sign_in_failed",
3497
+ "bad_signature",
3498
+ "missing_token"
3499
+ ];
3500
+ function agentDurationBucket(ms) {
3501
+ if (ms < 1e3) return "lt_1s";
3502
+ if (ms < 1e4) return "1_10s";
3503
+ if (ms < 6e4) return "10_60s";
3504
+ return "gt_60s";
3505
+ }
3506
+ var EVENT_PROPERTY_RULES = {
3507
+ account_created: {},
3508
+ "item.created": { kind: itemKind, size_bytes: count, size_bucket: sizeBucket },
3509
+ "item.downloaded": { size_bytes: count, size_bucket: sizeBucket },
3510
+ "item.copied": {},
3511
+ "item.shared": { kind: itemKind },
3512
+ "item.pinned": { age_ms: count },
3513
+ "item.unpinned": {},
3514
+ "item.deleted": { age_ms: count, was_pinned: bool, kind: itemKindOrUnknown },
3515
+ "item.departed": {},
3516
+ channel_shared: {},
3517
+ channel_mode_set: { mode: oneOf("edit", "view") },
3518
+ invite_created: {},
3519
+ invite_link_copied: {},
3520
+ invite_qr_shown: {},
3521
+ invite_system_sheet_opened: {},
3522
+ invite_joined: {},
3523
+ invite_converted: {},
3524
+ "channel.invite_preview": {
3525
+ outcome: oneOf("ready", "unavailable", "missing_key"),
3526
+ signed_in: bool
3527
+ },
3528
+ "channel.invite_accept": {
3529
+ outcome: oneOf("started", "completed", "already_member", "unavailable", "failed")
3530
+ },
3531
+ "channel.invite_signin": {
3532
+ stage: oneOf("link_requested", "link_sent", "link_completed", "failed")
3533
+ },
3534
+ "enterprise.invite_preview": {
3535
+ outcome: oneOf("pending", "resumable", "unavailable"),
3536
+ role: enterpriseRole
3537
+ },
3538
+ "enterprise.invite_identity": {
3539
+ outcome: oneOf("matched", "mismatched", "unavailable", "failed")
3540
+ },
3541
+ "enterprise.invite_signin": {
3542
+ stage: oneOf("link_requested", "link_sent", "link_completed", "failed")
3543
+ },
3544
+ "enterprise.invite_account_switch": { stage: oneOf("started", "completed", "failed") },
3545
+ "enterprise.invite_attach": {
3546
+ stage: oneOf("link_requested", "link_sent", "verify_started", "completed", "address_taken", "link_invalid", "failed")
3547
+ },
3548
+ "enterprise.invite_accept": {
3549
+ outcome: oneOf("started", "completed", "recovered", "timeout", "unavailable", "email_mismatch", "seat_limit", "failed"),
3550
+ role: enterpriseRole,
3551
+ destination: oneOf("workspace", "admin_console"),
3552
+ duration_ms: count
3553
+ },
3554
+ "enterprise.invite_admin": {
3555
+ outcome: oneOf("created", "email_sent", "email_failed", "reissued", "revoked"),
3556
+ role: enterpriseRole,
3557
+ external: bool
3558
+ },
3559
+ "enterprise.workspace_opened": { role: enterpriseRole, first_visit: bool },
3560
+ "enterprise.workspace_switched": { destination: oneOf("personal", "organization"), role: enterpriseRole },
3561
+ "enterprise.channel_created": { member_count: count },
3562
+ "enterprise.channel_members_added": { member_count: count },
3563
+ share_link_copied: {},
3564
+ share_qr_shown: {},
3565
+ share_system_sheet_opened: {},
3566
+ share_opened: {},
3567
+ share_content_copied: {},
3568
+ share_downloaded: {},
3569
+ share_signin_click: {},
3570
+ "share.saved": {},
3571
+ share_bar_on: {},
3572
+ share_bar_off: {},
3573
+ "upload.phase_timing": {
3574
+ stage: oneOf("staging", "bootstrap", "thumbnail", "encrypting", "uploading", "finishing"),
3575
+ duration_ms: count,
3576
+ size_bucket: sizeBucket,
3577
+ outcome: oneOf("success", "failure", "cancelled"),
3578
+ attempt: { type: "number", min: 1 }
3579
+ },
3580
+ "auth.signin_started": { provider: oneOf("google.com", "apple.com") },
3581
+ "auth.signin_result": {
3582
+ provider: oneOf("google.com", "apple.com"),
3583
+ outcome: oneOf("signed_in", "cancelled", "taken", "failed", "needs_link"),
3584
+ flow: oneOf("signin", "link", "connect")
3585
+ },
3586
+ "account_switch.started": { operation: oneOf("remember", "switch"), provider: oneOf("google.com", "apple.com", "email_link", "qr", "code") },
3587
+ "account_switch.completed": { operation: oneOf("remember", "switch"), provider: oneOf("google.com", "apple.com", "email_link", "qr", "code") },
3588
+ "account_switch.failed": { operation: oneOf("remember", "switch"), provider: oneOf("google.com", "apple.com", "email_link", "qr", "code") },
3589
+ "auth.session_started": { cold: bool },
3590
+ "auth.signed_out": { reason: oneOf("user", "account_switch", "abandoned_new_account", "account_deleted") },
3591
+ "auth.session_lost": { session_age_ms: count, foreground: bool },
3592
+ "auth.token_refresh_failed": {
3593
+ code: oneOf("network", "token_invalid", "user_disabled", "user_missing", "rate_limited", "internal", "unknown")
3594
+ },
3595
+ "bootstrap.result": {
3596
+ outcome: oneOf("offline", "ok", "timeout_warm", "timeout", "failed_warm", "failed"),
3597
+ stage: oneOf("cached", "done", "unknown", "keyring", "account_init", "listeners"),
3598
+ duration_ms: count,
3599
+ fault: oneOf("intercepted", "offline", "unknown")
3600
+ },
3601
+ direct_mode_set: { on: bool },
3602
+ "direct.offered": { size_bucket: sizeBucket },
3603
+ // `via_agent`: the offer came from a paired agent's process, not from one
3604
+ // of the person's devices. The claim and the two receiver outcomes carry it.
3605
+ "direct.claimed": { size_bucket: sizeBucket, via_agent: bool },
3606
+ "direct.connected": { role, ms: count },
3607
+ "direct.completed": {
3608
+ via: oneOf("lan", "wan", "relay", "unknown"),
3609
+ pair_local: candidateType,
3610
+ pair_remote: candidateType,
3611
+ connect_ms: count,
3612
+ duration_ms: count,
3613
+ size_bucket: sizeBucket,
3614
+ turn_urls_supplied: count,
3615
+ turn_urls_configured: count,
3616
+ restarts: count
3617
+ },
3618
+ "direct.received": {
3619
+ via_agent: bool,
3620
+ via: oneOf("lan", "wan", "relay", "unknown"),
3621
+ pair_local: candidateType,
3622
+ pair_remote: candidateType,
3623
+ connect_ms: count,
3624
+ duration_ms: count,
3625
+ size_bucket: sizeBucket,
3626
+ turn_urls_supplied: count,
3627
+ turn_urls_configured: count,
3628
+ restarts: count
3629
+ },
3630
+ "direct.failed": {
3631
+ role,
3632
+ via_agent: bool,
3633
+ reason: oneOf(
3634
+ "peer_silent",
3635
+ "connect_timeout",
3636
+ "ice_failed",
3637
+ "disconnected",
3638
+ "peer_closed",
3639
+ "peer_abort",
3640
+ "signaling",
3641
+ "signal_send",
3642
+ "protocol",
3643
+ "sink",
3644
+ "transfer",
3645
+ "integrity",
3646
+ "too_big",
3647
+ "unknown"
3648
+ ),
3649
+ ms: count,
3650
+ ice_state: oneOf("", "new", "checking", "connected", "completed", "failed", "disconnected", "closed"),
3651
+ gather_state: oneOf("", "new", "gathering", "complete"),
3652
+ had_remote: bool,
3653
+ local_host: count,
3654
+ local_srflx: count,
3655
+ local_relay: count,
3656
+ remote_host: count,
3657
+ remote_srflx: count,
3658
+ remote_relay: count,
3659
+ turn_urls_supplied: count,
3660
+ turn_urls_configured: count,
3661
+ moved_bucket: sizeBucket,
3662
+ size_bucket: sizeBucket,
3663
+ restarts: count,
3664
+ // Whether the reporting tab was on screen when the run died — the lead a
3665
+ // slept machine or a frozen background tab leaves behind.
3666
+ visibility: oneOf("visible", "hidden", "prerender")
3667
+ },
3668
+ "direct.fallback_started": { size_bucket: sizeBucket },
3669
+ "direct.fallback_ready": { size_bucket: sizeBucket, duration_ms: count, retries: count },
3670
+ "direct.fallback_received": { size_bucket: sizeBucket, duration_ms: count, retries: count },
3671
+ "direct.fallback_failed": {
3672
+ stage: oneOf("upload", "download"),
3673
+ size_bucket: sizeBucket,
3674
+ retries: count
3675
+ },
3676
+ "direct.fallback_aborted": {},
3677
+ "direct.too_big": { size_bucket: sizeBucket },
3678
+ "agent.pair_opened": { agent_kind: agentKind, protocol: count, replaces: bool },
3679
+ "agent.pair_approved": {
3680
+ agent_kind: agentKind,
3681
+ destination: oneOf("new_channel", "existing"),
3682
+ channels: count,
3683
+ handoff: pairHandoff
3684
+ },
3685
+ "agent.pair_refused": {
3686
+ agent_kind: agentKind,
3687
+ step: oneOf("open", "approve", "claim"),
3688
+ reason: oneOf(
3689
+ "agent_limit",
3690
+ "grant_limit",
3691
+ "channel_limit",
3692
+ "code_mismatch",
3693
+ "pairing_expired",
3694
+ "pairing_cancelled",
3695
+ "pairing_claimed",
3696
+ "feature_disabled",
3697
+ "rate_limited",
3698
+ "network",
3699
+ "other"
3700
+ )
3701
+ },
3702
+ "agent.paired": {
3703
+ agent_kind: agentKind,
3704
+ destination: oneOf("new_channel", "existing"),
3705
+ channels: count,
3706
+ handoff: pairHandoff,
3707
+ protocol: count
3708
+ },
3709
+ "agent.revoked": { agent_kind: agentKind },
3710
+ "agent.send": { size_bucket: sizeBucket, agent_kind: agentKind },
3711
+ // What the CLI reports after every tool call: the tool, whether it worked,
3712
+ // the code when it did not, and the version that answered. No path, no file
3713
+ // name, no channel, no sentence.
3714
+ "agent.tool_call": {
3715
+ tool: oneOf(...AGENT_TOOL_NAMES),
3716
+ result: oneOf("ok", "error"),
3717
+ code: oneOf(...AGENT_ERROR_CODES),
3718
+ agent_kind: agentKind,
3719
+ duration_bucket: oneOf("lt_1s", "1_10s", "10_60s", "gt_60s"),
3720
+ version_major: count,
3721
+ version_minor: count,
3722
+ version_patch: count
3723
+ }
3724
+ };
3725
+ var platformRule = oneOf("web", "android", "ios", "agent", "unknown");
3726
+ var PERSON_PROPERTY_RULES = {
3727
+ plan: oneOf("anon", "free", "pro", "max"),
3728
+ locale: oneOf("es-AR", "pt-BR", "en"),
3729
+ storage_bytes: count
3730
+ };
3731
+
3732
+ // src/telemetry.ts
3733
+ var OFF_WORDS = /* @__PURE__ */ new Set(["0", "off", "false", "no"]);
3734
+ var ON_WORDS = /* @__PURE__ */ new Set(["1", "on", "true", "yes"]);
3735
+ function telemetryState() {
3736
+ const env = (process.env.ZAS_AGENT_TELEMETRY ?? "").toLowerCase();
3737
+ if (OFF_WORDS.has(env)) return { on: false, source: "env" };
3738
+ if (ON_WORDS.has(env)) return { on: true, source: "env" };
3739
+ if (process.env.DO_NOT_TRACK === "1") return { on: false, source: "do_not_track" };
3740
+ const stored = loadSettings();
3741
+ if (stored && typeof stored.telemetry === "boolean") return { on: stored.telemetry, source: "file" };
3742
+ return { on: true, source: "default" };
3743
+ }
3744
+ function setTelemetry(on) {
3745
+ const stored = loadSettings();
3746
+ saveSettings({ ...stored ?? {}, version: 1, telemetry: on, decided_at: Date.now() });
3747
+ }
3748
+ function noticeShown() {
3749
+ return typeof loadSettings()?.notified_at === "number";
3750
+ }
3751
+ function markNoticeShown() {
3752
+ const stored = loadSettings();
3753
+ saveSettings({ ...stored ?? {}, version: 1, notified_at: Date.now() });
3754
+ }
3755
+ var TELEMETRY_NOTICE = [
3756
+ "Zas collects usage data from this agent to improve the product: which tool",
3757
+ "ran, whether it worked, the error code when it did not, how long it took,",
3758
+ "and the version. Never file names, file contents, paths or channel names.",
3759
+ "The data is linked to your Zas account.",
3760
+ "Turn it off with: npx -y zas-agent telemetry off"
3761
+ ].join("\n");
3762
+ var SOURCE_WORDS = {
3763
+ env: "ZAS_AGENT_TELEMETRY",
3764
+ do_not_track: "DO_NOT_TRACK",
3765
+ file: "zas-agent telemetry",
3766
+ default: "default"
3767
+ };
3768
+ function telemetryLine() {
3769
+ const state = telemetryState();
3770
+ return `telemetry: ${state.on ? "on" : "off"} (${SOURCE_WORDS[state.source]})`;
3771
+ }
3772
+ var TOOL_NAMES = new Set(AGENT_TOOL_NAMES);
3773
+ function versionParts(version) {
3774
+ const [major = 0, minor = 0, patch = 0] = version.split("-")[0].split(".").map((part) => {
3775
+ const value = Number.parseInt(part, 10);
3776
+ return Number.isFinite(value) && value >= 0 ? value : 0;
3777
+ });
3778
+ return { major, minor, patch };
3779
+ }
3780
+ function toolCallProperties(input) {
3781
+ const tool = input.tool.startsWith("zas_") ? input.tool.slice(4) : input.tool;
3782
+ if (!TOOL_NAMES.has(tool)) return null;
3783
+ const version = versionParts(input.version);
3784
+ return {
3785
+ tool,
3786
+ result: input.code === null ? "ok" : "error",
3787
+ // `other` is the server's word for a code outside the vocabulary. The
3788
+ // shared list is the one that decides, and it drops anything else.
3789
+ code: input.code ?? "none",
3790
+ duration_bucket: agentDurationBucket(input.ms),
3791
+ version_major: version.major,
3792
+ version_minor: version.minor,
3793
+ version_patch: version.patch
3794
+ };
3795
+ }
3796
+ var REPORT_TIMEOUT_MS = 5e3;
3797
+ async function report(client, properties) {
3798
+ if (properties === null || !telemetryState().on) return;
3799
+ const timeout = new Promise((resolve2) => {
3800
+ const timer = setTimeout(resolve2, REPORT_TIMEOUT_MS);
3801
+ timer.unref?.();
3802
+ });
3803
+ const sent = client.api("POST", "/agents/telemetry", { event: "agent.tool_call", properties }).then(() => void 0).catch(() => void 0);
3804
+ await Promise.race([sent, timeout]);
3805
+ }
3806
+
3331
3807
  // src/server.ts
3332
3808
  function agentVersion() {
3333
- return true ? "0.5.1" : "0.0.0-dev";
3809
+ return true ? "0.6.0" : "0.0.0-dev";
3334
3810
  }
3335
3811
  var AGENT_CUE = " The owner sees every item this agent sends with the >_ agent mark and this agent's name, on every device.";
3336
3812
  var PAIR_ANNOUNCE_MS = 15e3;
@@ -3344,9 +3820,33 @@ function rightsOf(grant) {
3344
3820
  if (grant.read) rights.push(grant.direct_mode ? "receive (Directo)" : "read");
3345
3821
  return rights.length > 0 ? rights.join(" \xB7 ") : "no access";
3346
3822
  }
3823
+ function answeredCode(answer) {
3824
+ if (!answer?.isError) return null;
3825
+ const match = /^([a-z_]{2,40}):/.exec(answer.content?.[0]?.text ?? "");
3826
+ return match ? match[1] : "other";
3827
+ }
3347
3828
  function buildServer(profile, deps = {}) {
3348
3829
  const server = new McpServer({ name: "zas", version: agentVersion() });
3349
3830
  const runner = deps.runner ?? new JobRunner();
3831
+ const reportCall = async (tool, ms, code) => {
3832
+ try {
3833
+ if (!(deps.identity ?? loadIdentity(profile))) return;
3834
+ await report(ctx().client, toolCallProperties({ tool, ms, code, version: agentVersion() }));
3835
+ } catch {
3836
+ }
3837
+ };
3838
+ const registerRaw = server.registerTool.bind(server);
3839
+ server.registerTool = (name, meta, handler) => registerRaw(name, meta, async (...args) => {
3840
+ const started = Date.now();
3841
+ try {
3842
+ const answer = await handler(...args);
3843
+ void reportCall(name, Date.now() - started, answeredCode(answer));
3844
+ return answer;
3845
+ } catch (e) {
3846
+ void reportCall(name, Date.now() - started, e instanceof ZasError ? e.code : "internal");
3847
+ throw e;
3848
+ }
3849
+ });
3350
3850
  let client = null;
3351
3851
  const ctx = () => {
3352
3852
  const identity = deps.identity ?? loadIdentity(profile);
@@ -3394,7 +3894,8 @@ function buildServer(profile, deps = {}) {
3394
3894
  `Paired as \u201C${c.identity.name}\u201D (${c.identity.kind}) with account ${c.identity.owner_uid}.`,
3395
3895
  grants.length > 0 ? "Channels:" : "No channels: the owner has not granted access to any yet.",
3396
3896
  ...lines,
3397
- `${packageName()} ${agentVersion()} \xB7 profile ${profile}`
3897
+ `${packageName()} ${agentVersion()} \xB7 profile ${profile}`,
3898
+ `${telemetryLine()} \xB7 change it with \u201Cnpx -y ${packageName()} telemetry off\u201D`
3398
3899
  ].join("\n"));
3399
3900
  } catch (e) {
3400
3901
  return failed(e);
@@ -3488,7 +3989,7 @@ function buildServer(profile, deps = {}) {
3488
3989
  "file",
3489
3990
  input.title ?? input.path,
3490
3991
  input.channel ?? "",
3491
- (report) => sendFile(c, input, report)
3992
+ (report2) => sendFile(c, input, report2)
3492
3993
  );
3493
3994
  return settled(await runner.wait(job));
3494
3995
  } catch (e) {
@@ -3514,7 +4015,7 @@ function buildServer(profile, deps = {}) {
3514
4015
  "direct",
3515
4016
  input.path,
3516
4017
  input.channel ?? "",
3517
- (report) => sendDirect(c, input, report, {
4018
+ (report2) => sendDirect(c, input, report2, {
3518
4019
  ...deps.direct ?? {},
3519
4020
  onFailed: (record) => rememberFailed(job.id, record)
3520
4021
  })
@@ -3538,7 +4039,7 @@ function buildServer(profile, deps = {}) {
3538
4039
  "fallback",
3539
4040
  record.name,
3540
4041
  record.channel_name,
3541
- (report) => sendDirectFallback(c, record, report, deps.direct)
4042
+ (report2) => sendDirectFallback(c, record, report2, deps.direct)
3542
4043
  );
3543
4044
  const outcome = await runner.wait(job);
3544
4045
  if (outcome.status === "done") failedDirects.delete(input.job);
@@ -3566,7 +4067,7 @@ function buildServer(profile, deps = {}) {
3566
4067
  "receive",
3567
4068
  input.dest ?? "Directo",
3568
4069
  input.channel ?? "",
3569
- (report) => receiveDirect(c, input, report, {
4070
+ (report2) => receiveDirect(c, input, report2, {
3570
4071
  ...deps.receive ?? {},
3571
4072
  onFailed: (record) => rememberFailedReceive(job.id, record)
3572
4073
  })
@@ -3590,7 +4091,7 @@ function buildServer(profile, deps = {}) {
3590
4091
  "fallback",
3591
4092
  record.meta.name,
3592
4093
  record.channel_name,
3593
- (report) => receiveDirectFallback(c, record, report, deps.receive)
4094
+ (report2) => receiveDirectFallback(c, record, report2, deps.receive)
3594
4095
  );
3595
4096
  const outcome = await runner.wait(job);
3596
4097
  if (outcome.status === "done") failedReceives.delete(input.job);
@@ -3667,7 +4168,16 @@ function parseArgs(argv) {
3667
4168
  const inline = arg.startsWith("--") && eq > 0 ? arg.slice(eq + 1) : void 0;
3668
4169
  const take = () => inline !== void 0 ? inline : argv[++i] ?? "";
3669
4170
  if (name === "pair") parsed.command = "pair";
3670
- else if (name === "--version" || name === "-v") parsed.command = "version";
4171
+ else if (name === "telemetry") {
4172
+ parsed.command = "telemetry";
4173
+ const next = argv[i + 1];
4174
+ if (next === "on" || next === "off") {
4175
+ parsed.telemetry = next;
4176
+ i++;
4177
+ } else if (next !== void 0 && !next.startsWith("--")) {
4178
+ return { ...parsed, command: "invalid", message: `Unknown telemetry setting \u201C${next}\u201D. Use \u201Con\u201D or \u201Coff\u201D.` };
4179
+ }
4180
+ } else if (name === "--version" || name === "-v") parsed.command = "version";
3671
4181
  else if (name === "--help" || name === "-h") parsed.command = "help";
3672
4182
  else if (name === "--profile") {
3673
4183
  parsed.profile = take() || DEFAULT_PROFILE;
@@ -3690,6 +4200,7 @@ var USAGE = [
3690
4200
  " zas-agent [--profile <name>] serve the MCP tools over stdio",
3691
4201
  " zas-agent pair [--profile <name>] pair this agent with a Zas account",
3692
4202
  " [--kind claude_code|codex|other] [--host <name>] [--no-open]",
4203
+ " zas-agent telemetry [on|off] say what this machine reports, or change it",
3693
4204
  " zas-agent --version"
3694
4205
  ].join("\n");
3695
4206
  async function main(argv, log = (l) => console.error(l)) {
@@ -3707,7 +4218,17 @@ async function main(argv, log = (l) => console.error(l)) {
3707
4218
  log(USAGE);
3708
4219
  return args.unknown ? 2 : 0;
3709
4220
  }
4221
+ if (args.command === "telemetry") {
4222
+ if (args.telemetry) setTelemetry(args.telemetry === "on");
4223
+ markNoticeShown();
4224
+ log(telemetryLine());
4225
+ log(args.telemetry === "off" ? `Nothing is sent. Turn it on again with: npx -y ${packageName()} telemetry on` : TELEMETRY_NOTICE);
4226
+ return 0;
4227
+ }
3710
4228
  if (args.command === "pair") {
4229
+ log(TELEMETRY_NOTICE);
4230
+ log("");
4231
+ markNoticeShown();
3711
4232
  let rl = null;
3712
4233
  const askCode = process.stdin.isTTY ? () => {
3713
4234
  rl ??= createInterface({ input: process.stdin, output: process.stderr });
@@ -3736,6 +4257,10 @@ async function main(argv, log = (l) => console.error(l)) {
3736
4257
  }
3737
4258
  return 0;
3738
4259
  }
4260
+ if (!noticeShown()) {
4261
+ log(TELEMETRY_NOTICE);
4262
+ markNoticeShown();
4263
+ }
3739
4264
  await buildServer(args.profile).connect(new StdioServerTransport());
3740
4265
  return 0;
3741
4266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zas-agent",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server that lets a coding agent send files and notes into your Zas channels, and send or receive files live through Directo.",
5
5
  "type": "module",
6
6
  "license": "MIT",