zas-agent 0.8.0 → 0.9.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,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-09-09
11
+
12
+ ### Added
13
+
14
+ - `zas_edit_item`. An agent changes an item it sent, under the same id: the
15
+ title of a file or a note, or a note's text, language and secret cover.
16
+ The owner's activity log records the edit, and the channel's devices are
17
+ woken. An item somebody else sent is refused as `not_yours`.
18
+ - `zas_replace_file`. An agent swaps the bytes under a file it sent, keeping
19
+ the item id, its place in the channel and its pin. The old chunks are
20
+ released and the new ones charged in one server transaction; a chunk in
21
+ both versions is left alone. A note, an item with a public share, and an
22
+ item somebody else sent are refused. Agents only: no web or mobile client
23
+ gets a replace.
24
+ - Five codes: `not_yours`, `stale` (the item changed while the agent was
25
+ working on it: read it again and retry), `not_a_note`, `not_a_file`,
26
+ `item_shared`.
27
+ - Both tools drop the local send receipts for the item they changed, so
28
+ "send that again" after an edit makes a new item.
29
+
30
+ ### Fixed
31
+
32
+ - A machine whose agent home could not be written crashed on the way into
33
+ serving, before the MCP initialize response, because recording that the
34
+ telemetry notice had been shown threw. The notice is printed and the
35
+ failure to record it is logged; the server starts either way. This is the
36
+ crash behind "connection closed: initialize response" from a Codex-style
37
+ host.
38
+
39
+
10
40
  ## [0.8.0] - 2026-09-06
11
41
 
12
42
  ### Added
@@ -303,7 +333,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
303
333
  two separate identities that cannot read each other's keys.
304
334
  - Install snippets for Claude Code and Codex, printed by `zas-agent pair`.
305
335
 
306
- [Unreleased]: https://github.com/soke1556/zas-agent/compare/v0.8.0...HEAD
336
+ [Unreleased]: https://github.com/soke1556/zas-agent/compare/v0.9.0...HEAD
337
+ [0.9.0]: https://github.com/soke1556/zas-agent/compare/v0.8.0...v0.9.0
307
338
  [0.8.0]: https://github.com/soke1556/zas-agent/compare/v0.7.0...v0.8.0
308
339
  [0.7.0]: https://github.com/soke1556/zas-agent/compare/v0.6.3...v0.7.0
309
340
  [0.6.3]: https://github.com/soke1556/zas-agent/compare/v0.6.2...v0.6.3
package/README.md CHANGED
@@ -25,9 +25,10 @@ scope to the channels you choose, and that you can revoke.
25
25
 
26
26
  ## What it does
27
27
 
28
- Your coding agent gets eleven tools. It can send a file or a note into a channel
28
+ Your coding agent gets thirteen tools. It can send a file or a note into a channel
29
29
  you picked, send or receive a file live through Directo, list what is in that
30
- channel, and pull one item back onto disk.
30
+ channel, pull one item back onto disk, and change what it sent: an item's
31
+ title or text, or the bytes under a file, keeping the item id.
31
32
  Everything it sends is encrypted on your machine before it leaves, lands in your
32
33
  account, and is marked in the channel as sent by that agent. The agent has an
33
34
  identity of its own and never holds your account key: pairing mints a key pair
@@ -166,6 +167,11 @@ ones marked *(server-side)* are enforced by the Zas server.
166
167
  - **No stored sending into a view-only channel, and none into a channel in
167
168
  Directo mode.** Both are refused before a byte is uploaded. A channel in
168
169
  Directo mode takes `zas_send_direct`, a live transfer that stores nothing.
170
+ - **No changing what it did not send.** `zas_edit_item` and `zas_replace_file`
171
+ act only on items carrying this agent's mark; anything else is `not_yours`,
172
+ and the server refuses it too *(server-side)*. A replace is for agents only:
173
+ no web or mobile client gets one. An item with a public share is not
174
+ replaced until the share is removed.
169
175
  - **No receiving unless the grant says read.** Receiving through Directo
170
176
  writes a file onto this machine, so `zas_receive_direct` takes the same
171
177
  `read` switch as listing. And it only ever runs inside a tool call: the
@@ -213,6 +219,11 @@ string ever reaches a terminal.
213
219
  | `file_too_big` | The file is over the plan limit. |
214
220
  | `duplicate` | That item is already in the channel. |
215
221
  | `not_found` | That item is not in the channel. |
222
+ | `not_yours` | That item was not sent by this agent; it can only change its own items. |
223
+ | `stale` | That item changed while this agent was working on it; read it again and retry. |
224
+ | `not_a_note` | That item is a file, not a note; only its title can change. |
225
+ | `not_a_file` | That item is a note, not a file. |
226
+ | `item_shared` | That item has a public share; the owner removes the share first. |
216
227
  | `invalid_cap` | That file is no longer available. |
217
228
  | `write_failed` | The download destination could not be written. |
218
229
  | `pairing_expired` | The pairing expired; pair again. |
@@ -243,11 +254,22 @@ stack trace.
243
254
  | `zas_receive_direct_fallback` | After a Directo receive failed in flight, downloads the encrypted copy the sender chose to store, and decrypts it to the same destination. |
244
255
  | `zas_list_items` | Lists the most recent items in one of the owner's channels. Needs a grant that includes reading. |
245
256
  | `zas_get_item` | Fetches one item. A note comes back as text; a file is written to disk. It never overwrites, so the path it answers with can differ from the one you asked for. |
257
+ | `zas_edit_item` | Changes an item this agent sent, keeping its id: the title of a file or a note, or a note's text, language and secret cover. Needs a grant that includes reading and sending. The owner's activity log records it. |
258
+ | `zas_replace_file` | Replaces the bytes of a file this agent sent with a file from this machine, keeping the item id, its place in the channel and its pin. Returns the item id, or a job id after a minute. Refuses notes, shared items, and items sent by anyone else. |
246
259
  | `zas_jobs` | Lists the sends and Directo transfers this server started, newest first, with the phase each one reached — and where a `job_id` from a long send is redeemed. |
247
260
 
248
261
  `channel` takes a channel name or a channel id. A name has to match exactly one
249
262
  of the channels you granted; with exactly one grant, `zas_send_file`,
250
- `zas_send_note`, `zas_send_direct` and `zas_receive_direct` can leave it out.
263
+ `zas_send_note`, `zas_send_direct`, `zas_receive_direct`, `zas_edit_item` and
264
+ `zas_replace_file` can leave it out.
265
+
266
+ A replace runs the same pipeline as a send — hash, key derivation, encryption,
267
+ then upload or proof per chunk — and posts the new set with the old one to
268
+ release. The server moves both sets, the account's storage tally and the item
269
+ in one transaction, so a failure leaves everything as it was; a chunk present
270
+ in both versions is neither uploaded nor released. Both tools read the item
271
+ first and write only over the version they read: an item that changed in
272
+ between answers `stale`, and nothing is lost.
251
273
 
252
274
  Directo needs a native module, [node-datachannel](https://github.com/murat-dogan/node-datachannel),
253
275
  WebRTC for Node. npm installs a prebuilt binary for Windows, macOS and Linux;
package/dist/cli.js CHANGED
@@ -123,7 +123,13 @@ var RENAMES = {
123
123
  // agent (expired, or cancelled from the receiving side); `direct_too_big`
124
124
  // is the plan's Directo ceiling.
125
125
  not_live: "direct_cancelled",
126
- direct_too_big: "file_too_big"
126
+ direct_too_big: "file_too_big",
127
+ // The item-change routes. `not_allowed` there means the item is not this
128
+ // agent's; a share of any kind, armed or not, is the one reason a replace
129
+ // is refused that the owner fixes from the item itself.
130
+ not_allowed: "not_yours",
131
+ shared: "item_shared",
132
+ bar_armed: "item_shared"
127
133
  };
128
134
  var ZasError = class extends Error {
129
135
  constructor(code, status, message, retryAfterMs, serverCode) {
@@ -181,6 +187,11 @@ var SENTENCES = {
181
187
  rate_limited: "Too many sends in a row. Wait a moment.",
182
188
  file_too_big: "The file is over the plan limit.",
183
189
  duplicate: "That item is already in the channel.",
190
+ not_yours: "That item was not sent by this agent. It can only change its own items.",
191
+ stale: "That item changed while this agent was working on it. Read it again and retry.",
192
+ not_a_note: "That item is a file, not a note. Only its title can change; use zas_replace_file for the bytes.",
193
+ not_a_file: "That item is a note, not a file. Use zas_edit_item.",
194
+ item_shared: "That item has a public share. The owner removes the share first.",
184
195
  pairing_expired: "The pairing expired. Run \u201Czas-agent pair\u201D again.",
185
196
  pairing_cancelled: "The owner cancelled the pairing.",
186
197
  claim_mismatch: "The code does not match. Try again.",
@@ -2566,32 +2577,20 @@ async function postLink(ctx, grant, manifest, placed, idempotencyKey, expiresInD
2566
2577
  }
2567
2578
  return created.link_id;
2568
2579
  }
2569
- async function sendFile(ctx, input, onPhase) {
2570
- const stat = await fsp2.stat(input.path).catch(() => {
2580
+ async function checkFilePath(path) {
2581
+ const stat = await fsp2.stat(path).catch(() => {
2571
2582
  throw new ZasError("upload_failed", 400);
2572
2583
  });
2573
2584
  if (!stat.isFile()) throw new ZasError("upload_failed", 400);
2574
2585
  if (stat.size > MAX_FILE_BYTES) throw new ZasError("file_too_big", 413);
2575
- const grant = await grantFor(ctx, input.channel);
2576
- const name = basename(input.path);
2577
- const title = input.title ?? name;
2578
- const file = await fsp2.readFile(input.path);
2586
+ }
2587
+ async function readFileChecked(path) {
2588
+ await checkFilePath(path);
2589
+ const file = await fsp2.readFile(path);
2579
2590
  if (file.byteLength > MAX_FILE_BYTES) throw new ZasError("file_too_big", 413);
2580
- const bytes = new Uint8Array(file.buffer, file.byteOffset, file.byteLength);
2581
- const contentHash = await sendIdentity(await blake3Hex(bytes), input.expires_in_days);
2582
- const key = await receiptKey(ctx, grant.channel_id, contentHash, title);
2583
- const stored = receiptFor(ctx, key);
2584
- if (stored) {
2585
- return {
2586
- link_id: stored.link_id,
2587
- channel_id: grant.channel_id,
2588
- channel_name: channelNameOf(ctx.identity, grant),
2589
- bytes: stored.bytes,
2590
- chunks: stored.chunks,
2591
- deduplicated: stored.deduplicated,
2592
- replayed: true
2593
- };
2594
- }
2591
+ return { bytes: new Uint8Array(file.buffer, file.byteOffset, file.byteLength), name: basename(path) };
2592
+ }
2593
+ async function placeFile(ctx, bytes, onPhase) {
2595
2594
  onPhase?.("hashing");
2596
2595
  const plains = [];
2597
2596
  for await (const piece of chunkStream([bytes])) plains.push(piece);
@@ -2608,7 +2607,37 @@ async function sendFile(ctx, input, onPhase) {
2608
2607
  (plain, i) => encryptChunk(oprfFinalize(hashes[i], blinds[i].blind, b64ToBytes(evaluated[i])), plain)
2609
2608
  ));
2610
2609
  onPhase?.("uploading");
2611
- const placed = await placeChunks(ctx, encs);
2610
+ return placeChunks(ctx, encs);
2611
+ }
2612
+ function forgetLink(profile, linkId) {
2613
+ const entries = {};
2614
+ let dropped = false;
2615
+ for (const [k, v] of Object.entries(loadFingerprints(profile).entries)) {
2616
+ if (v && v.link_id === linkId) dropped = true;
2617
+ else entries[k] = v;
2618
+ }
2619
+ if (dropped) saveFingerprints(profile, { entries });
2620
+ }
2621
+ async function sendFile(ctx, input, onPhase) {
2622
+ await checkFilePath(input.path);
2623
+ const grant = await grantFor(ctx, input.channel);
2624
+ const { bytes, name } = await readFileChecked(input.path);
2625
+ const title = input.title ?? name;
2626
+ const contentHash = await sendIdentity(await blake3Hex(bytes), input.expires_in_days);
2627
+ const key = await receiptKey(ctx, grant.channel_id, contentHash, title);
2628
+ const stored = receiptFor(ctx, key);
2629
+ if (stored) {
2630
+ return {
2631
+ link_id: stored.link_id,
2632
+ channel_id: grant.channel_id,
2633
+ channel_name: channelNameOf(ctx.identity, grant),
2634
+ bytes: stored.bytes,
2635
+ chunks: stored.chunks,
2636
+ deduplicated: stored.deduplicated,
2637
+ replayed: true
2638
+ };
2639
+ }
2640
+ const placed = await placeFile(ctx, bytes, onPhase);
2612
2641
  onPhase?.("finishing");
2613
2642
  const mime = mimeFor(input.path);
2614
2643
  const thumb = await thumbnailFor(bytes, mime);
@@ -2971,20 +3000,6 @@ async function sendDirectFallback(ctx, record, report2, deps = {}) {
2971
3000
  };
2972
3001
  }
2973
3002
 
2974
- // src/receive.ts
2975
- import { randomBytes as randomBytes3 } from "node:crypto";
2976
- import {
2977
- closeSync as closeSync2,
2978
- existsSync as existsSync3,
2979
- mkdirSync as mkdirSync3,
2980
- openSync as openSync2,
2981
- renameSync as renameSync3,
2982
- rmdirSync as rmdirSync2,
2983
- unlinkSync as unlinkSync2,
2984
- writeSync as writeSync2
2985
- } from "node:fs";
2986
- import { dirname as dirname2 } from "node:path";
2987
-
2988
3003
  // src/read.ts
2989
3004
  import { randomBytes as randomBytes2 } from "node:crypto";
2990
3005
  import {
@@ -3024,7 +3039,8 @@ function rowOf(doc) {
3024
3039
  agent: stringOf(fields.agent),
3025
3040
  createdAt: timeOf(fields.created_at),
3026
3041
  expiresAt: timeOf(fields.expires_at),
3027
- bar: fields.bar?.booleanValue === true
3042
+ bar: fields.bar?.booleanValue === true,
3043
+ updateTime: timeOf({ timestampValue: document.updateTime })
3028
3044
  };
3029
3045
  }
3030
3046
  function readable(row) {
@@ -3171,7 +3187,7 @@ function destinationOf(dest, name, fallback) {
3171
3187
  const at = statSync2(dest, { throwIfNoEntry: false })?.isDirectory() ? join2(dest, base) : dest;
3172
3188
  return { target: freeName(at) };
3173
3189
  }
3174
- async function getItem(ctx, channel, id, dest) {
3190
+ async function loadItem(ctx, channel, id) {
3175
3191
  const grant = await readGrant(ctx, channel);
3176
3192
  if (!ID_SEGMENT.test(id)) throw new ZasError("not_found", 404);
3177
3193
  const channelKey = channelKeyOf(ctx.identity, grant);
@@ -3190,6 +3206,10 @@ async function getItem(ctx, channel, id, dest) {
3190
3206
  if (!row || !readable(row)) throw new ZasError("not_found", 404);
3191
3207
  const manifest = openFor(channelKey, row);
3192
3208
  if (!manifest) throw new ZasError("not_found", 404);
3209
+ return { grant, channelKey, row, manifest };
3210
+ }
3211
+ async function getItem(ctx, channel, id, dest) {
3212
+ const { row, manifest } = await loadItem(ctx, channel, id);
3193
3213
  if (manifest.kind === "text") {
3194
3214
  const text2 = manifest.text ?? "";
3195
3215
  return { text: text2, bytes: new TextEncoder().encode(text2).length };
@@ -3240,7 +3260,140 @@ async function getItem(ctx, channel, id, dest) {
3240
3260
  return { path: target, bytes: written };
3241
3261
  }
3242
3262
 
3263
+ // src/edit.ts
3264
+ function writable(grant) {
3265
+ if (!grant.send || grant.mode === "view") throw new ZasError("send_forbidden", 403);
3266
+ if (grant.direct_mode) throw new ZasError("direct_mode", 409);
3267
+ }
3268
+ async function ownItem(ctx, channel, id) {
3269
+ const loaded = await loadItem(ctx, channel, id);
3270
+ writable(loaded.grant);
3271
+ if (loaded.row.agent !== ctx.identity.agent_uid) throw new ZasError("not_yours", 403);
3272
+ return loaded;
3273
+ }
3274
+ function sealedFor(item, manifest) {
3275
+ return bytesToB64(sealManifest(item.channelKey, manifest, item.grant.key_version));
3276
+ }
3277
+ function guardOf(item) {
3278
+ return item.row.updateTime === null ? {} : { expected_update_time: item.row.updateTime };
3279
+ }
3280
+ async function editItem(ctx, input) {
3281
+ const item = await ownItem(ctx, input.channel, input.id);
3282
+ const { grant, row, manifest } = item;
3283
+ const kind = manifest.kind === "text" ? "text" : "file";
3284
+ const noteFields = input.text !== void 0 || input.lang !== void 0 || input.secret !== void 0;
3285
+ if (noteFields && kind !== "text") throw new ZasError("not_a_note", 400);
3286
+ const done = (updateTime) => ({
3287
+ link_id: row.id,
3288
+ channel_id: grant.channel_id,
3289
+ channel_name: channelNameOf(ctx.identity, grant),
3290
+ kind,
3291
+ title: manifest.title ?? manifest.name,
3292
+ update_time: updateTime
3293
+ });
3294
+ if (input.title === void 0 && !noteFields) return done(row.updateTime ?? Date.now());
3295
+ if (input.title !== void 0) {
3296
+ if (input.title === "") delete manifest.title;
3297
+ else manifest.title = input.title;
3298
+ }
3299
+ if (input.text !== void 0) {
3300
+ manifest.text = input.text;
3301
+ manifest.size = new TextEncoder().encode(input.text).length;
3302
+ delete manifest.html;
3303
+ if (manifest.title === void 0) manifest.name = noteName(input.text);
3304
+ }
3305
+ if (input.lang !== void 0) {
3306
+ if (input.lang === "") delete manifest.code;
3307
+ else manifest.code = { lang: input.lang, auto: false };
3308
+ }
3309
+ if (input.secret !== void 0) {
3310
+ if (input.secret) manifest.sensitive = true;
3311
+ else delete manifest.sensitive;
3312
+ }
3313
+ const answer = await ctx.client.api(
3314
+ "PATCH",
3315
+ `/links/${grant.channel_id}/${row.id}`,
3316
+ {
3317
+ manifest_enc: sealedFor(item, manifest),
3318
+ // The caller is changing what the item says: the server records it.
3319
+ edit: true,
3320
+ ...guardOf(item)
3321
+ }
3322
+ );
3323
+ forgetLink(ctx.profile, row.id);
3324
+ return done(typeof answer.update_time === "number" ? answer.update_time : Date.now());
3325
+ }
3326
+ async function replaceFile(ctx, input, onPhase) {
3327
+ await checkFilePath(input.path);
3328
+ const item = await ownItem(ctx, input.channel, input.id);
3329
+ const { grant, row, manifest } = item;
3330
+ if (manifest.kind === "text") throw new ZasError("not_a_file", 400);
3331
+ const { bytes, name } = await readFileChecked(input.path);
3332
+ const title = input.title ?? manifest.title;
3333
+ const contentHash = await blake3Hex(bytes);
3334
+ const placed = await placeFile(ctx, bytes, onPhase);
3335
+ onPhase?.("finishing");
3336
+ const mime = mimeFor(input.path);
3337
+ const thumb = await thumbnailFor(bytes, mime);
3338
+ const next = newManifest({
3339
+ kind: "file",
3340
+ name,
3341
+ ...title !== void 0 ? { title } : {},
3342
+ mime,
3343
+ size: bytes.length,
3344
+ // The item's own time: a replace changes what the item holds, not when the
3345
+ // owner got it, and the row keeps its place in the channel either way.
3346
+ created_at: manifest.created_at || (/* @__PURE__ */ new Date()).toISOString(),
3347
+ ...thumb ? { thumb_data: thumb } : {},
3348
+ chunks: placed.map((p) => p.entry)
3349
+ });
3350
+ const release = manifest.chunks.map((c) => c.cap).filter((cap) => typeof cap === "string");
3351
+ const answer = await ctx.client.api("POST", `/links/${grant.channel_id}/${row.id}/replace`, {
3352
+ manifest_enc: sealedFor(item, next),
3353
+ caps: placed.map((p) => p.entry.cap).filter((cap) => typeof cap === "string"),
3354
+ release,
3355
+ // Scoped to the link as well as the channel: the same bytes sent as a new
3356
+ // item are a different request from these bytes replacing this one.
3357
+ idempotency_key: agentSendIdempotencyKey(`${grant.channel_id}/${row.id}`, contentHash, title ?? name),
3358
+ ...guardOf(item)
3359
+ });
3360
+ const bound = answer.caps ?? {};
3361
+ let patched = false;
3362
+ for (const p of placed) {
3363
+ const cap = bound[p.entry.blob_id];
3364
+ if (cap) {
3365
+ p.entry.cap = cap;
3366
+ patched = true;
3367
+ }
3368
+ }
3369
+ if (patched) {
3370
+ await ctx.client.api("PATCH", `/links/${grant.channel_id}/${row.id}`, { manifest_enc: sealedFor(item, next) });
3371
+ }
3372
+ forgetLink(ctx.profile, row.id);
3373
+ return {
3374
+ link_id: row.id,
3375
+ channel_id: grant.channel_id,
3376
+ channel_name: channelNameOf(ctx.identity, grant),
3377
+ bytes: bytes.length,
3378
+ chunks: placed.length,
3379
+ deduplicated: placed.filter((p) => p.proven).length,
3380
+ replayed: answer.replayed === true
3381
+ };
3382
+ }
3383
+
3243
3384
  // src/receive.ts
3385
+ import { randomBytes as randomBytes3 } from "node:crypto";
3386
+ import {
3387
+ closeSync as closeSync2,
3388
+ existsSync as existsSync3,
3389
+ mkdirSync as mkdirSync3,
3390
+ openSync as openSync2,
3391
+ renameSync as renameSync3,
3392
+ rmdirSync as rmdirSync2,
3393
+ unlinkSync as unlinkSync2,
3394
+ writeSync as writeSync2
3395
+ } from "node:fs";
3396
+ import { dirname as dirname2 } from "node:path";
3244
3397
  var OFFER_WAIT_MS2 = 9.5 * 60 * 1e3;
3245
3398
  var OFFER_POLL_MS2 = 1e3;
3246
3399
  var SIGNAL_POLL_MS2 = 400;
@@ -3658,6 +3811,8 @@ var PRODUCT_EVENTS = [
3658
3811
  "enterprise.invite_attach",
3659
3812
  "enterprise.invite_accept",
3660
3813
  "enterprise.invite_admin",
3814
+ "enterprise.signin",
3815
+ "enterprise.org_create",
3661
3816
  "enterprise.workspace_opened",
3662
3817
  "enterprise.workspace_switched",
3663
3818
  "enterprise.channel_created",
@@ -3702,7 +3857,17 @@ var PRODUCT_EVENTS = [
3702
3857
  "agent.paired",
3703
3858
  "agent.revoked",
3704
3859
  "agent.send",
3705
- "agent.tool_call"
3860
+ "agent.edit",
3861
+ "agent.replace",
3862
+ "agent.tool_call",
3863
+ // The public pages, which until now reported nothing at all: `capture_pageview`
3864
+ // is off, so a visitor who read the enterprise page and left was invisible.
3865
+ // These five are the funnel that ends at `enterprise.org_create`.
3866
+ "marketing.page_viewed",
3867
+ "marketing.section_viewed",
3868
+ "marketing.scroll_depth",
3869
+ "marketing.cta_clicked",
3870
+ "marketing.language_switched"
3706
3871
  ];
3707
3872
  var SERVER_OWNED_PRODUCT_EVENTS = [
3708
3873
  "account_created",
@@ -3715,6 +3880,8 @@ var SERVER_OWNED_PRODUCT_EVENTS = [
3715
3880
  "share_downloaded",
3716
3881
  "share_signin_click",
3717
3882
  "agent.send",
3883
+ "agent.edit",
3884
+ "agent.replace",
3718
3885
  "agent.tool_call"
3719
3886
  ];
3720
3887
  var SERVER_OWNED_PRODUCT_EVENT_SET = new Set(SERVER_OWNED_PRODUCT_EVENTS);
@@ -3732,6 +3899,30 @@ var candidateType = oneOf("", "host", "srflx", "relay", "prflx", "other");
3732
3899
  var enterpriseRole = oneOf("owner", "admin", "billing_admin", "member", "guest");
3733
3900
  var agentKind = oneOf("claude_code", "codex", "other");
3734
3901
  var pairHandoff = oneOf("loopback", "manual", "unknown");
3902
+ var marketingSurface = oneOf("home", "plans", "enterprise");
3903
+ var marketingSection = oneOf(
3904
+ "enterprise_band",
3905
+ "hero",
3906
+ "what",
3907
+ "benefits",
3908
+ "fit",
3909
+ "billing",
3910
+ "signin"
3911
+ );
3912
+ var marketingCta = oneOf(
3913
+ "see_enterprise",
3914
+ "see_plans",
3915
+ "contact",
3916
+ "start_org",
3917
+ "sign_in",
3918
+ "open_console",
3919
+ "switch_language",
3920
+ "footer_home"
3921
+ );
3922
+ var marketingPlacement = oneOf("nav", "hero", "band", "fit", "billing", "signin", "footer");
3923
+ var arrivalKind = oneOf("direct", "internal", "search", "social", "other");
3924
+ var uiLocale = oneOf("es-AR", "pt-BR", "en");
3925
+ var signinContext = oneOf("page", "handoff_site", "handoff_share");
3735
3926
  var AGENT_TOOL_NAMES = [
3736
3927
  "status",
3737
3928
  "pair",
@@ -3743,7 +3934,9 @@ var AGENT_TOOL_NAMES = [
3743
3934
  "receive_direct_fallback",
3744
3935
  "list_items",
3745
3936
  "get_item",
3746
- "jobs"
3937
+ "jobs",
3938
+ "edit_item",
3939
+ "replace_file"
3747
3940
  ];
3748
3941
  var AGENT_ERROR_CODES = [
3749
3942
  "none",
@@ -3788,7 +3981,13 @@ var AGENT_ERROR_CODES = [
3788
3981
  "network",
3789
3982
  "sign_in_failed",
3790
3983
  "bad_signature",
3791
- "missing_token"
3984
+ "missing_token",
3985
+ // An agent changing its own items (2026-09-09).
3986
+ "not_yours",
3987
+ "stale",
3988
+ "not_a_note",
3989
+ "not_a_file",
3990
+ "item_shared"
3792
3991
  ];
3793
3992
  function agentDurationBucket(ms) {
3794
3993
  if (ms < 1e3) return "lt_1s";
@@ -3849,6 +4048,24 @@ var EVENT_PROPERTY_RULES = {
3849
4048
  role: enterpriseRole,
3850
4049
  external: bool
3851
4050
  },
4051
+ // The workspace sign-in card, wherever it is shown: the public enterprise
4052
+ // page, a `?site=` handoff from a customer domain, or a `?share=` link.
4053
+ "enterprise.signin": {
4054
+ context: signinContext,
4055
+ stage: oneOf(
4056
+ "shown",
4057
+ "email_submitted",
4058
+ "email_sent",
4059
+ "email_failed",
4060
+ "provider_clicked",
4061
+ "link_completed",
4062
+ "link_failed"
4063
+ ),
4064
+ provider: oneOf("google.com", "apple.com", "email_link", "none")
4065
+ },
4066
+ // The end of the funnel. `opened` is the create form appearing, which is
4067
+ // what someone signing in with no organization yet is shown.
4068
+ "enterprise.org_create": { stage: oneOf("opened", "submitted", "created", "failed") },
3852
4069
  "enterprise.workspace_opened": { role: enterpriseRole, first_visit: bool },
3853
4070
  "enterprise.workspace_switched": { destination: oneOf("personal", "organization"), role: enterpriseRole },
3854
4071
  "enterprise.channel_created": { member_count: count },
@@ -4001,9 +4218,32 @@ var EVENT_PROPERTY_RULES = {
4001
4218
  },
4002
4219
  "agent.revoked": { agent_kind: agentKind },
4003
4220
  "agent.send": { size_bucket: sizeBucket, agent_kind: agentKind },
4221
+ "agent.edit": { agent_kind: agentKind },
4222
+ "agent.replace": { size_bucket: sizeBucket, agent_kind: agentKind },
4004
4223
  // What the CLI reports after every tool call: the tool, whether it worked,
4005
4224
  // the code when it did not, and the version that answered. No path, no file
4006
4225
  // name, no channel, no sentence.
4226
+ "marketing.page_viewed": {
4227
+ surface: marketingSurface,
4228
+ locale: uiLocale,
4229
+ // Whether the URL carried a language prefix. `/en/enterprise` and
4230
+ // `/enterprise` are the same page in different languages, and which one
4231
+ // search sends people to is worth knowing on its own.
4232
+ prefixed: bool,
4233
+ signed_in: bool,
4234
+ arrival: arrivalKind
4235
+ },
4236
+ "marketing.section_viewed": { surface: marketingSurface, section: marketingSection },
4237
+ "marketing.scroll_depth": {
4238
+ surface: marketingSurface,
4239
+ depth: oneOf("p25", "p50", "p75", "p100")
4240
+ },
4241
+ "marketing.cta_clicked": {
4242
+ surface: marketingSurface,
4243
+ cta: marketingCta,
4244
+ placement: marketingPlacement
4245
+ },
4246
+ "marketing.language_switched": { surface: marketingSurface, from: uiLocale, to: uiLocale },
4007
4247
  "agent.tool_call": {
4008
4248
  tool: oneOf(...AGENT_TOOL_NAMES),
4009
4249
  result: oneOf("ok", "error"),
@@ -4099,7 +4339,7 @@ async function report(client, properties) {
4099
4339
 
4100
4340
  // src/server.ts
4101
4341
  function agentVersion() {
4102
- return true ? "0.8.0" : "0.0.0-dev";
4342
+ return true ? "0.9.0" : "0.0.0-dev";
4103
4343
  }
4104
4344
  var AGENT_CUE = " The owner sees every item this agent sends with the >_ agent mark and this agent's name, on every device.";
4105
4345
  var PAIR_ANNOUNCE_MS = 15e3;
@@ -4456,6 +4696,45 @@ ${formatDirectDiagLines(job.diag).join("\n")}`) };
4456
4696
  return failed(e);
4457
4697
  }
4458
4698
  });
4699
+ server.registerTool("zas_edit_item", {
4700
+ description: "Change an item this agent sent, keeping its id: the title of a file or a note, or a note's text, language and secret cover. Refuses items sent by anyone else. A file's bytes change with zas_replace_file. Needs a grant that includes reading and sending. Pass only the fields to change; an empty title clears it." + AGENT_CUE,
4701
+ inputSchema: {
4702
+ id: z.string().describe("Item id, as `zas_list_items` reports it."),
4703
+ channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel."),
4704
+ title: z.string().optional().describe("New label. An empty string clears it, so the file name or the first line shows again."),
4705
+ text: z.string().optional().describe("New body, for a note."),
4706
+ lang: z.string().optional().describe('Language of the snippet, for highlighting (for example "ts", "py"). An empty string makes it plain text.'),
4707
+ secret: z.boolean().optional().describe("Hide the body behind a cover until the reader opens it. False removes the cover.")
4708
+ }
4709
+ }, async (input) => {
4710
+ try {
4711
+ return text2(await editItem(ctx(), input));
4712
+ } catch (e) {
4713
+ return failed(e);
4714
+ }
4715
+ });
4716
+ server.registerTool("zas_replace_file", {
4717
+ description: "Replace the bytes of a file this agent sent with a file from this machine, keeping the item id, its place in the channel and its pin. Refuses items sent by anyone else, notes, and an item with a public share. Returns the item id, or a job id when the upload takes longer than a minute. Sends any file this process can read; confirm with the owner before sending secrets, keys or credentials." + AGENT_CUE,
4718
+ inputSchema: {
4719
+ id: z.string().describe("Item id, as `zas_list_items` reports it."),
4720
+ path: z.string().describe("Absolute or relative path of the new file."),
4721
+ channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel."),
4722
+ title: z.string().optional().describe("New label. Defaults to the label the item has.")
4723
+ }
4724
+ }, async (input) => {
4725
+ try {
4726
+ const c = ctx();
4727
+ const job = runner.start(
4728
+ "replace",
4729
+ input.title ?? input.path,
4730
+ input.channel ?? "",
4731
+ (report2) => replaceFile(c, input, report2)
4732
+ );
4733
+ return settled(await runner.wait(job));
4734
+ } catch (e) {
4735
+ return failed(e);
4736
+ }
4737
+ });
4459
4738
  server.registerTool("zas_jobs", {
4460
4739
  description: "List the sends and Directo transfers this server started, newest first, with the phase each one reached and how it ended \u2014 including any `job_id` a send returned; a finished job keeps its result here." + AGENT_CUE
4461
4740
  }, async () => text2(runner.list()));
@@ -4509,6 +4788,15 @@ var USAGE = [
4509
4788
  " zas-agent telemetry [on|off] say what this machine reports, or change it",
4510
4789
  " zas-agent --version"
4511
4790
  ].join("\n");
4791
+ function noticeOnServe(log) {
4792
+ try {
4793
+ if (noticeShown()) return;
4794
+ log(TELEMETRY_NOTICE);
4795
+ markNoticeShown();
4796
+ } catch (e) {
4797
+ log(`Could not record that the notice was shown (${String(e?.message ?? e)}). Serving anyway.`);
4798
+ }
4799
+ }
4512
4800
  async function main(argv, log = (l) => console.error(l)) {
4513
4801
  const args = parseArgs(argv);
4514
4802
  if (args.command === "version") {
@@ -4563,10 +4851,7 @@ async function main(argv, log = (l) => console.error(l)) {
4563
4851
  }
4564
4852
  return 0;
4565
4853
  }
4566
- if (!noticeShown()) {
4567
- log(TELEMETRY_NOTICE);
4568
- markNoticeShown();
4569
- }
4854
+ noticeOnServe(log);
4570
4855
  await buildServer(args.profile).connect(new StdioServerTransport());
4571
4856
  return 0;
4572
4857
  }
@@ -4603,5 +4888,6 @@ if (isInvokedDirectly(process.argv[1], import.meta.url)) {
4603
4888
  export {
4604
4889
  isInvokedDirectly,
4605
4890
  main,
4891
+ noticeOnServe,
4606
4892
  parseArgs
4607
4893
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zas-agent",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",