zas-agent 0.6.0 → 0.6.2

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/dist/cli.js +242 -111
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.2] - 2026-09-04
11
+
12
+ ### Added
13
+
14
+ - The trace records the peer connection state, not only the ICE state.
15
+ ICE `connected` means a candidate pair answered; the peer connection
16
+ reaches `connected` only once DTLS completes. A session that holds an ICE
17
+ path and sends nothing over it used to look the same as one that never
18
+ found a path at all.
19
+ - A failure that had a working ICE pair is now `channel_never_opened`, not
20
+ `connect_timeout`. Same clock, different fault: one is the candidate hunt,
21
+ the other is the handshake above it.
22
+ - The verdict line carries the peer connection state and the data channel's
23
+ own state, so a stalled DTLS handshake and a stalled SCTP association can
24
+ be told apart from one line.
25
+ - The selected candidate pair is read when ICE connects, not only when the
26
+ data channel opens. A run that connected and then went quiet reported
27
+ `pair=none` and looked as if it had never selected one.
28
+ - `onicecandidateerror` is traced, by URL shape and error code only. A
29
+ gather that ends with no relay candidate now says whether the allocation
30
+ was refused or never answered.
31
+
32
+ ## [0.6.1] - 2026-09-04
33
+
34
+ ### Added
35
+
36
+ - A Directo run now answers with the engine's own account of itself, not
37
+ only with one word. `zas_send_direct`, `zas_receive_direct` and `zas_jobs`
38
+ carry the candidate counts by type for both ends, the ICE and gathering
39
+ states, how many TURN URLs were supplied against how many the connection
40
+ kept, the selected pair, the restarts, and a timestamped trace of the
41
+ session: every candidate as it arrived, the state changes, and the verdict.
42
+ Types, transports and timings only — never an address, a port, a host name
43
+ or a credential. A run that fails on a machine you are not sitting at can
44
+ now be diagnosed from the tool result.
45
+ - The thrown error behind a failure is kept as `detail`. The reason stays a
46
+ closed word for grouping; `detail` is the sentence the exception carried,
47
+ which every failure used to discard at the catch.
48
+
49
+ ### Fixed
50
+
51
+ - The receiver pre-gathers a candidate pool. An agent hands its whole
52
+ candidate set over at once, so the far end could start checking before its
53
+ own relay candidates existed, connect on the first pair it found, and be
54
+ left with nothing when that pair died seconds later.
55
+ - The transport of a candidate is read whichever way the peer wrote the line.
56
+ This package prefixes the SDP attribute, so its candidates used to be
57
+ reported with an unknown transport.
58
+
10
59
  ## [0.6.0] - 2026-09-03
11
60
 
12
61
  ### Added
@@ -156,7 +205,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
156
205
  two separate identities that cannot read each other's keys.
157
206
  - Install snippets for Claude Code and Codex, printed by `zas-agent pair`.
158
207
 
159
- [Unreleased]: https://github.com/soke1556/zas-agent/compare/v0.3.0...HEAD
208
+ [Unreleased]: https://github.com/soke1556/zas-agent/compare/v0.6.2...HEAD
209
+ [0.6.2]: https://github.com/soke1556/zas-agent/compare/v0.6.1...v0.6.2
210
+ [0.6.1]: https://github.com/soke1556/zas-agent/compare/v0.6.0...v0.6.1
211
+ [0.6.0]: https://github.com/soke1556/zas-agent/compare/v0.5.1...v0.6.0
212
+ [0.5.1]: https://github.com/soke1556/zas-agent/compare/v0.5.0...v0.5.1
213
+ [0.5.0]: https://github.com/soke1556/zas-agent/compare/v0.4.0...v0.5.0
214
+ [0.4.0]: https://github.com/soke1556/zas-agent/compare/v0.3.0...v0.4.0
160
215
  [0.3.0]: https://github.com/soke1556/zas-agent/compare/v0.2.1...v0.3.0
161
216
  [0.2.1]: https://github.com/soke1556/zas-agent/compare/v0.2.0...v0.2.1
162
217
  [0.2.0]: https://github.com/soke1556/zas-agent/compare/v0.1.1...v0.2.0
package/dist/cli.js CHANGED
@@ -830,89 +830,6 @@ async function runPair(opts) {
830
830
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
831
831
  import { z } from "zod";
832
832
 
833
- // src/direct.ts
834
- import { randomBytes } from "node:crypto";
835
- import { openAsBlob } from "node:fs";
836
- import { basename as basename2 } from "node:path";
837
-
838
- // src/shared/manifest.ts
839
- import { xchacha20poly1305 } from "@noble/ciphers/chacha";
840
-
841
- // src/shared/constants.ts
842
- var CHUNKER_VERSION = "chunker_v1";
843
- var CHUNK_MIN = 1 * 1024 * 1024;
844
- var CHUNK_AVG_BITS = 22;
845
- var CHUNK_MAX = 8 * 1024 * 1024;
846
- var STORAGE_V2_PART_BYTES = 16 * 1024 * 1024;
847
- var GEAR_SEED = "zas-gear-v1:";
848
- var HKDF_INFO_CHUNK_KEY = "zas-chunk-key-v1";
849
- var HKDF_INFO_CHUNK_NONCE = "zas-chunk-nonce-v1";
850
- var OPRF_CONTEXT = "OPRFV1-\0-ristretto255-SHA512";
851
- var MANIFEST_VERSION = 1;
852
- var ANON_EMPTY_TTL_MS = 30 * 60 * 1e3;
853
- var FREE_RELAY_MAX_BYTES = 50 * 1024 * 1024;
854
- var SAFE_STORED_FILE_LIMITS = {
855
- anon: 30 * 1024 * 1024,
856
- free: FREE_RELAY_MAX_BYTES,
857
- pro: 200 * 1024 * 1024,
858
- max: 200 * 1024 * 1024
859
- };
860
- var STORED_FILE_LIMITS_V2 = {
861
- anon: 30 * 1024 * 1024,
862
- free: FREE_RELAY_MAX_BYTES,
863
- pro: 5 * 1024 * 1024 * 1024,
864
- max: 20 * 1024 * 1024 * 1024
865
- };
866
- var STORAGE_LIMITS = {
867
- anon: 30 * 1024 * 1024,
868
- free: 100 * 1024 * 1024,
869
- pro: null,
870
- max: null
871
- };
872
- var THUMBNAIL_MAX_EDGE = 640;
873
- var THUMBNAIL_MAX_BYTES = 14e4;
874
-
875
- // src/shared/manifest.ts
876
- function newManifest(partial) {
877
- return { v: MANIFEST_VERSION, chunker: CHUNKER_VERSION, ...partial };
878
- }
879
- var ENVELOPE_MAGIC = 90;
880
- var KEY_VERSION_LEGACY = 1;
881
- function envelopeVersion(sealed) {
882
- if (sealed.length > 26 && sealed[0] === ENVELOPE_MAGIC && sealed[1] >= 1) return sealed[1];
883
- return KEY_VERSION_LEGACY;
884
- }
885
- function sealBytes(key, version, plain) {
886
- if (version < 1 || version > 255) throw new Error("key version out of range");
887
- const nonce = new Uint8Array(24);
888
- crypto.getRandomValues(nonce);
889
- const ct = xchacha20poly1305(key, nonce).encrypt(plain);
890
- return concatBytes(new Uint8Array([ENVELOPE_MAGIC, version]), nonce, ct);
891
- }
892
- function openSealed(key, sealed) {
893
- const versioned = envelopeVersion(sealed) !== KEY_VERSION_LEGACY || sealed[0] === ENVELOPE_MAGIC;
894
- if (versioned && sealed.length > 26) {
895
- try {
896
- return xchacha20poly1305(key, sealed.slice(2, 26)).decrypt(sealed.slice(26));
897
- } catch {
898
- }
899
- }
900
- return xchacha20poly1305(key, sealed.slice(0, 24)).decrypt(sealed.slice(24));
901
- }
902
- var sealRaw = sealBytes;
903
- var openRaw = openSealed;
904
- function sealManifest(channelKey, manifest, version = KEY_VERSION_LEGACY) {
905
- return sealBytes(channelKey, version, new TextEncoder().encode(JSON.stringify(manifest)));
906
- }
907
- function openManifest(channelKey, sealed) {
908
- const m = JSON.parse(new TextDecoder().decode(openSealed(channelKey, sealed)));
909
- if (!Array.isArray(m.chunks)) m.chunks = [];
910
- return m;
911
- }
912
- function decryptChannelName(channelKey, sealed) {
913
- return new TextDecoder().decode(openSealed(channelKey, sealed));
914
- }
915
-
916
833
  // src/shared/direct.ts
917
834
  var DIRECT_OFFER_TTL_MS = 10 * 60 * 1e3;
918
835
  var DIRECT_CLAIMED_TTL_MS = 6 * 60 * 60 * 1e3;
@@ -948,6 +865,13 @@ var FRAME_CHUNK = 2;
948
865
  var FRAME_DONE = 3;
949
866
  var FRAME_ABORT = 4;
950
867
  var FRAME_CREDIT = 5;
868
+ function directMetaOf(file, fallbackName) {
869
+ return {
870
+ name: file.name || fallbackName || "zas",
871
+ size: file.size,
872
+ mime: file.type || "application/octet-stream"
873
+ };
874
+ }
951
875
  function encodeMeta(meta) {
952
876
  const body = new TextEncoder().encode(JSON.stringify(meta));
953
877
  const frame = new Uint8Array(1 + body.length);
@@ -1020,9 +944,66 @@ function candType(candidate) {
1020
944
  const m = /\styp\s+(\S+)/.exec(candidate?.candidate ?? "");
1021
945
  return m ? m[1] : "other";
1022
946
  }
947
+ function candProto(candidate) {
948
+ const line = candidate?.candidate ?? "";
949
+ const m = /^(?:a=)?(?:candidate:)?\S+\s+\d+\s+(\S+)/.exec(line);
950
+ const proto = m ? m[1].toLowerCase() : "?";
951
+ const tcp = /\stcptype\s+(\S+)/.exec(line);
952
+ return tcp ? `${proto}/${tcp[1]}` : proto;
953
+ }
954
+ var TRACE_MAX = 200;
955
+ var DETAIL_MAX = 200;
956
+ function errText(e) {
957
+ const raw = e instanceof Error ? e.message || e.name : String(e);
958
+ const line = raw.replace(/\s+/g, " ").trim();
959
+ return line.length > DETAIL_MAX ? `${line.slice(0, DETAIL_MAX)}\u2026` : line;
960
+ }
961
+ var traceStart = /* @__PURE__ */ new WeakMap();
962
+ function traceAdd(diag, line) {
963
+ if (diag.trace.length >= TRACE_MAX) return;
964
+ if (diag.trace.length === TRACE_MAX - 1) {
965
+ diag.trace.push("trace full");
966
+ return;
967
+ }
968
+ const t0 = traceStart.get(diag);
969
+ const at = t0 === void 0 ? 0 : (Date.now() - t0) / 1e3;
970
+ diag.trace.push(`${at.toFixed(2)} ${line}`);
971
+ }
972
+ function urlShape(url) {
973
+ const scheme = /^(stuns?|turns?):/i.exec(url)?.[1]?.toLowerCase() ?? "other";
974
+ const transport = /[?&]transport=(\w+)/i.exec(url)?.[1]?.toLowerCase();
975
+ return transport ? `${scheme}/${transport}` : scheme;
976
+ }
977
+ function iceShapes(servers) {
978
+ const seen = /* @__PURE__ */ new Map();
979
+ for (const server of servers ?? []) {
980
+ const urls = typeof server.urls === "string" ? [server.urls] : server.urls;
981
+ for (const url of urls ?? []) {
982
+ const shape = urlShape(url);
983
+ seen.set(shape, (seen.get(shape) ?? 0) + 1);
984
+ }
985
+ }
986
+ const parts = [...seen].map(([shape, n]) => `${shape}x${n}`);
987
+ return parts.length > 0 ? parts.join(" ") : "none";
988
+ }
989
+ function formatDirectDiagLines(d) {
990
+ const connect = d.msConnect === void 0 ? "-" : `${d.msConnect}ms`;
991
+ const pair = d.pairLocal || d.pairRemote ? `${d.pairLocal || "?"}/${d.pairRemote || "?"}` : "none";
992
+ return [
993
+ `ms=${d.ms} connect=${connect} bytes=${d.bytes} restarts=${d.restarts} pair=${pair}`,
994
+ ...d.detail === void 0 ? [] : [`detail ${d.detail}`],
995
+ `local host=${d.localHost} srflx=${d.localSrflx} relay=${d.localRelay} remote host=${d.remoteHost} srflx=${d.remoteSrflx} relay=${d.remoteRelay}`,
996
+ `turn supplied=${d.turnUrlsSupplied} configured=${d.turnUrlsConfigured} ice=${d.iceState} gather=${d.gatherState} remote_desc=${d.hadRemoteDesc}`,
997
+ ...d.trace ?? []
998
+ ];
999
+ }
1023
1000
  function countCand(diag, side, c) {
1024
- if (!c) return;
1001
+ if (!c) {
1002
+ traceAdd(diag, `${side} end-of-candidates`);
1003
+ return;
1004
+ }
1025
1005
  const t = candType(c);
1006
+ traceAdd(diag, `${side} ${t} ${candProto(c)}`);
1026
1007
  if (t === "host") diag[side === "local" ? "localHost" : "remoteHost"]++;
1027
1008
  else if (t === "srflx" || t === "prflx") diag[side === "local" ? "localSrflx" : "remoteSrflx"]++;
1028
1009
  else if (t === "relay") diag[side === "local" ? "localRelay" : "remoteRelay"]++;
@@ -1063,7 +1044,17 @@ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1063
1044
  const initialIce = ice ?? DIRECT_ICE;
1064
1045
  const pc = new RTCPeerConnection({
1065
1046
  iceServers: initialIce,
1066
- iceTransportPolicy
1047
+ iceTransportPolicy,
1048
+ // Gather before there is anything to gather for. A receiver whose peer
1049
+ // is an agent gets the whole remote candidate set at once — the agent
1050
+ // writes it and leaves — so checking starts in the same tick as the local
1051
+ // description, a pair comes up on the first candidate found, and
1052
+ // gathering is reported complete before any relay allocation returns.
1053
+ // Measured 2026-09-04: one host candidate, no relay, gathering done at
1054
+ // 240 ms, and when that pair died five seconds later there was nothing
1055
+ // to fall back to. The pool does the allocations up front, at construction,
1056
+ // so the relay candidates exist before the first check is ever sent.
1057
+ iceCandidatePoolSize: 1
1067
1058
  });
1068
1059
  let phase = "connecting";
1069
1060
  let disconnectTimer;
@@ -1085,7 +1076,22 @@ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1085
1076
  turnUrlsSupplied: countTurnUrls(initialIce),
1086
1077
  turnUrlsConfigured: 0,
1087
1078
  bytes: 0,
1088
- restarts: 0
1079
+ restarts: 0,
1080
+ trace: []
1081
+ };
1082
+ traceStart.set(diag, startedAt);
1083
+ traceAdd(diag, `ice servers ${iceShapes(initialIce)} policy=${iceTransportPolicy}`);
1084
+ pc.onicegatheringstatechange = () => {
1085
+ traceAdd(diag, `gather ${String(pc.iceGatheringState ?? "")}`);
1086
+ };
1087
+ const connState = () => String(pc.connectionState ?? "");
1088
+ pc.onconnectionstatechange = () => {
1089
+ traceAdd(diag, `conn ${connState()}`);
1090
+ };
1091
+ let channel;
1092
+ pc.onicecandidateerror = (e) => {
1093
+ const err = e;
1094
+ traceAdd(diag, `ice error ${err.url ? urlShape(err.url) : "?"} code=${err.errorCode ?? "?"}`);
1089
1095
  };
1090
1096
  const cleanup = () => {
1091
1097
  clearTimeout(waitTimer);
@@ -1094,6 +1100,9 @@ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1094
1100
  clearTimeout(restartTimer);
1095
1101
  pc.onicecandidate = null;
1096
1102
  pc.oniceconnectionstatechange = null;
1103
+ pc.onicegatheringstatechange = null;
1104
+ pc.onicecandidateerror = null;
1105
+ pc.onconnectionstatechange = null;
1097
1106
  pc.close();
1098
1107
  };
1099
1108
  const setPhase = (p) => {
@@ -1105,6 +1114,10 @@ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1105
1114
  diag.gatherState = String(pc.iceGatheringState ?? "");
1106
1115
  diag.hadRemoteDesc = hasRemoteDescription(pc);
1107
1116
  diag.turnUrlsConfigured = countTurnUrls(pc.getConfiguration().iceServers);
1117
+ traceAdd(
1118
+ diag,
1119
+ `${p}${diag.reason ? ` ${diag.reason}` : ""} ice=${diag.iceState} gather=${diag.gatherState} conn=${connState()} chan=${channel === void 0 ? "-" : String(channel.readyState ?? "")}`
1120
+ );
1108
1121
  onDiag?.(diag);
1109
1122
  onPhase(p);
1110
1123
  cleanup();
@@ -1112,9 +1125,13 @@ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1112
1125
  }
1113
1126
  onPhase(p);
1114
1127
  };
1115
- const fail = (reason) => {
1128
+ const fail = (reason, cause) => {
1116
1129
  if (phase === "done" || phase === "failed") return;
1117
1130
  if (!diag.reason) diag.reason = reason;
1131
+ if (cause !== void 0 && diag.detail === void 0) {
1132
+ diag.detail = errText(cause);
1133
+ traceAdd(diag, `detail ${diag.detail}`);
1134
+ }
1118
1135
  setPhase("failed");
1119
1136
  };
1120
1137
  const waitTimer = setTimeout(() => fail("peer_silent"), DIRECT_SIGNAL_WAIT_MS);
@@ -1124,7 +1141,10 @@ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1124
1141
  if (heard) return;
1125
1142
  heard = true;
1126
1143
  clearTimeout(waitTimer);
1127
- connectTimer = setTimeout(() => fail("connect_timeout"), DIRECT_CONNECT_TIMEOUT_MS);
1144
+ connectTimer = setTimeout(() => {
1145
+ const iceUp = pc.iceConnectionState === "connected" || pc.iceConnectionState === "completed";
1146
+ fail(iceUp ? "channel_never_opened" : "connect_timeout");
1147
+ }, DIRECT_CONNECT_TIMEOUT_MS);
1128
1148
  };
1129
1149
  let onStall;
1130
1150
  let stallWindowMs = DIRECT_CONNECT_TIMEOUT_MS;
@@ -1138,12 +1158,14 @@ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1138
1158
  }
1139
1159
  stallState = "restarting";
1140
1160
  diag.restarts++;
1161
+ traceAdd(diag, "ice restart");
1141
1162
  clearTimeout(restartTimer);
1142
1163
  restartTimer = setTimeout(() => fail("disconnected"), stallWindowMs);
1143
1164
  onStall();
1144
1165
  };
1145
1166
  pc.oniceconnectionstatechange = () => {
1146
1167
  const s = pc.iceConnectionState;
1168
+ traceAdd(diag, `ice ${String(s ?? "")}`);
1147
1169
  if (s === "failed") {
1148
1170
  if (diag.msConnect !== void 0 && onStall) stalled();
1149
1171
  else fail("ice_failed");
@@ -1156,6 +1178,13 @@ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1156
1178
  clearTimeout(disconnectTimer);
1157
1179
  clearTimeout(restartTimer);
1158
1180
  stallState = "idle";
1181
+ void pc.getStats().then((stats) => {
1182
+ const pair = pairFromStats(stats);
1183
+ if (!pair || diag.pairLocal) return;
1184
+ diag.pairLocal = pair.local;
1185
+ diag.pairRemote = pair.remote;
1186
+ traceAdd(diag, `pair ${pair.local}/${pair.remote}`);
1187
+ }).catch(() => void 0);
1159
1188
  }
1160
1189
  };
1161
1190
  return {
@@ -1168,15 +1197,20 @@ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1168
1197
  connected: () => {
1169
1198
  clearTimeout(waitTimer);
1170
1199
  clearTimeout(connectTimer);
1171
- if (diag.msConnect === void 0) diag.msConnect = Date.now() - startedAt;
1200
+ if (diag.msConnect === void 0) {
1201
+ diag.msConnect = Date.now() - startedAt;
1202
+ traceAdd(diag, "channel open");
1203
+ }
1172
1204
  },
1173
1205
  isTerminal: () => phase === "done" || phase === "failed",
1206
+ setChannel: (dc) => void (channel = dc),
1174
1207
  setStall: (fn, windowMs) => {
1175
1208
  onStall = fn;
1176
1209
  stallWindowMs = windowMs;
1177
1210
  },
1178
1211
  setIceServers: (iceServers) => {
1179
1212
  diag.turnUrlsSupplied = countTurnUrls(iceServers);
1213
+ traceAdd(diag, `ice servers replaced ${iceShapes(iceServers)}`);
1180
1214
  pc.setConfiguration({ iceServers });
1181
1215
  }
1182
1216
  };
@@ -1258,9 +1292,10 @@ function startSender(opts) {
1258
1292
  const offer = await pc.createOffer({ iceRestart: true });
1259
1293
  await pc.setLocalDescription(offer);
1260
1294
  await opts.send({ kind: "offer", sdp: offer.sdp, protocol: 2, generation });
1261
- })().catch(() => s.fail("signaling"));
1295
+ })().catch((e) => s.fail("signaling", e));
1262
1296
  }, DIRECT_CONNECT_TIMEOUT_MS);
1263
1297
  const dc = pc.createDataChannel("zas-direct", { ordered: true });
1298
+ s.setChannel(dc);
1264
1299
  dc.binaryType = "arraybuffer";
1265
1300
  dc.bufferedAmountLowThreshold = DIRECT_BUFFERED_HIGH / 8;
1266
1301
  const waitLow = () => new Promise((resolve2) => {
@@ -1285,9 +1320,7 @@ function startSender(opts) {
1285
1320
  };
1286
1321
  const pump = async () => {
1287
1322
  const meta = {
1288
- name: opts.file.name ?? opts.name ?? "zas",
1289
- size: opts.file.size,
1290
- mime: opts.file.type || "application/octet-stream",
1323
+ ...directMetaOf(opts.file, opts.name),
1291
1324
  ...opts.label !== void 0 ? { label: opts.label } : {}
1292
1325
  };
1293
1326
  dc.send(encodeMeta(meta));
@@ -1326,7 +1359,7 @@ function startSender(opts) {
1326
1359
  s.diag.pairRemote = pair.remote;
1327
1360
  opts.onPath?.(pathOf(pair));
1328
1361
  }).catch(() => void 0);
1329
- void pump().catch(() => s.fail("transfer"));
1362
+ void pump().catch((e) => s.fail("transfer", e));
1330
1363
  };
1331
1364
  dc.onmessage = (e) => {
1332
1365
  try {
@@ -1352,7 +1385,7 @@ function startSender(opts) {
1352
1385
  plumbing.setLocalGeneration(generation);
1353
1386
  plumbing.expectRemoteGeneration(generation);
1354
1387
  await opts.send({ kind: "offer", sdp: offer.sdp, protocol: 2, generation });
1355
- })().catch(() => s.fail("signaling"));
1388
+ })().catch((e) => s.fail("signaling", e));
1356
1389
  return {
1357
1390
  accept: (msg) => {
1358
1391
  if (s.isTerminal()) return;
@@ -1362,7 +1395,7 @@ function startSender(opts) {
1362
1395
  if (msg.generation !== void 0 && answerGeneration !== generation) return;
1363
1396
  peerProtocol = msg.protocol === 2 ? 2 : 1;
1364
1397
  plumbing.expectRemoteGeneration(answerGeneration);
1365
- void pc.setRemoteDescription({ type: "answer", sdp: msg.sdp }).then(() => plumbing.remoteDescriptionSet(answerGeneration)).catch(() => s.fail("signaling"));
1398
+ void pc.setRemoteDescription({ type: "answer", sdp: msg.sdp }).then(() => plumbing.remoteDescriptionSet(answerGeneration)).catch((e) => s.fail("signaling", e));
1366
1399
  }
1367
1400
  if (msg.kind === "ice") plumbing.applyIce(msg.candidate, msg.generation);
1368
1401
  },
@@ -1411,6 +1444,7 @@ function startReceiver(opts) {
1411
1444
  let finished = false;
1412
1445
  pc.ondatachannel = (e) => {
1413
1446
  dc = e.channel;
1447
+ s.setChannel(dc);
1414
1448
  dc.binaryType = "arraybuffer";
1415
1449
  dc.onopen = () => {
1416
1450
  s.connected();
@@ -1435,7 +1469,7 @@ function startReceiver(opts) {
1435
1469
  s.fail(frame.meta.size > DIRECT_FILE_MAX_BYTES ? "too_big" : "protocol");
1436
1470
  return;
1437
1471
  }
1438
- if (opts.expectedMeta && (frame.meta.name !== opts.expectedMeta.name || frame.meta.size !== opts.expectedMeta.size || frame.meta.mime !== opts.expectedMeta.mime)) {
1472
+ if (opts.expectedMeta && (frame.meta.name !== opts.expectedMeta.name || frame.meta.size !== opts.expectedMeta.size)) {
1439
1473
  dc?.send(ABORT_FRAME);
1440
1474
  s.fail("protocol");
1441
1475
  return;
@@ -1447,7 +1481,7 @@ function startReceiver(opts) {
1447
1481
  sinkRef = sink;
1448
1482
  return sink;
1449
1483
  });
1450
- pipeline.catch(() => s.fail("sink"));
1484
+ pipeline.catch((e3) => s.fail("sink", e3));
1451
1485
  } else if (frame.type === "chunk") {
1452
1486
  if (!pipeline || finished) throw new Error("chunk_before_meta");
1453
1487
  queued += frame.payload.length;
@@ -1465,7 +1499,7 @@ function startReceiver(opts) {
1465
1499
  }
1466
1500
  return sink;
1467
1501
  });
1468
- pipeline.catch(() => s.fail("sink"));
1502
+ pipeline.catch((e3) => s.fail("sink", e3));
1469
1503
  } else if (frame.type === "done") {
1470
1504
  if (!pipeline || finished) throw new Error("done_before_meta");
1471
1505
  finished = true;
@@ -1476,7 +1510,7 @@ function startReceiver(opts) {
1476
1510
  await sink.close();
1477
1511
  dc?.send(DONE_FRAME);
1478
1512
  s.setPhase("done");
1479
- }).catch(() => s.fail("integrity"));
1513
+ }).catch((e3) => s.fail("integrity", e3));
1480
1514
  } else if (frame.type === "abort") {
1481
1515
  s.fail("peer_abort");
1482
1516
  }
@@ -1510,7 +1544,7 @@ function startReceiver(opts) {
1510
1544
  const answer = await pc.createAnswer();
1511
1545
  await pc.setLocalDescription(answer);
1512
1546
  await opts.send({ kind: "answer", sdp: answer.sdp, protocol: 2, generation });
1513
- })().catch(() => s.fail("signaling"));
1547
+ })().catch((e) => s.fail("signaling", e));
1514
1548
  }
1515
1549
  if (msg.kind === "ice") plumbing.applyIce(msg.candidate, msg.generation);
1516
1550
  },
@@ -1527,6 +1561,89 @@ function startReceiver(opts) {
1527
1561
  };
1528
1562
  }
1529
1563
 
1564
+ // src/direct.ts
1565
+ import { randomBytes } from "node:crypto";
1566
+ import { openAsBlob } from "node:fs";
1567
+ import { basename as basename2 } from "node:path";
1568
+
1569
+ // src/shared/manifest.ts
1570
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha";
1571
+
1572
+ // src/shared/constants.ts
1573
+ var CHUNKER_VERSION = "chunker_v1";
1574
+ var CHUNK_MIN = 1 * 1024 * 1024;
1575
+ var CHUNK_AVG_BITS = 22;
1576
+ var CHUNK_MAX = 8 * 1024 * 1024;
1577
+ var STORAGE_V2_PART_BYTES = 16 * 1024 * 1024;
1578
+ var GEAR_SEED = "zas-gear-v1:";
1579
+ var HKDF_INFO_CHUNK_KEY = "zas-chunk-key-v1";
1580
+ var HKDF_INFO_CHUNK_NONCE = "zas-chunk-nonce-v1";
1581
+ var OPRF_CONTEXT = "OPRFV1-\0-ristretto255-SHA512";
1582
+ var MANIFEST_VERSION = 1;
1583
+ var ANON_EMPTY_TTL_MS = 30 * 60 * 1e3;
1584
+ var FREE_RELAY_MAX_BYTES = 50 * 1024 * 1024;
1585
+ var SAFE_STORED_FILE_LIMITS = {
1586
+ anon: 30 * 1024 * 1024,
1587
+ free: FREE_RELAY_MAX_BYTES,
1588
+ pro: 200 * 1024 * 1024,
1589
+ max: 200 * 1024 * 1024
1590
+ };
1591
+ var STORED_FILE_LIMITS_V2 = {
1592
+ anon: 30 * 1024 * 1024,
1593
+ free: FREE_RELAY_MAX_BYTES,
1594
+ pro: 5 * 1024 * 1024 * 1024,
1595
+ max: 20 * 1024 * 1024 * 1024
1596
+ };
1597
+ var STORAGE_LIMITS = {
1598
+ anon: 30 * 1024 * 1024,
1599
+ free: 100 * 1024 * 1024,
1600
+ pro: null,
1601
+ max: null
1602
+ };
1603
+ var THUMBNAIL_MAX_EDGE = 640;
1604
+ var THUMBNAIL_MAX_BYTES = 14e4;
1605
+
1606
+ // src/shared/manifest.ts
1607
+ function newManifest(partial) {
1608
+ return { v: MANIFEST_VERSION, chunker: CHUNKER_VERSION, ...partial };
1609
+ }
1610
+ var ENVELOPE_MAGIC = 90;
1611
+ var KEY_VERSION_LEGACY = 1;
1612
+ function envelopeVersion(sealed) {
1613
+ if (sealed.length > 26 && sealed[0] === ENVELOPE_MAGIC && sealed[1] >= 1) return sealed[1];
1614
+ return KEY_VERSION_LEGACY;
1615
+ }
1616
+ function sealBytes(key, version, plain) {
1617
+ if (version < 1 || version > 255) throw new Error("key version out of range");
1618
+ const nonce = new Uint8Array(24);
1619
+ crypto.getRandomValues(nonce);
1620
+ const ct = xchacha20poly1305(key, nonce).encrypt(plain);
1621
+ return concatBytes(new Uint8Array([ENVELOPE_MAGIC, version]), nonce, ct);
1622
+ }
1623
+ function openSealed(key, sealed) {
1624
+ const versioned = envelopeVersion(sealed) !== KEY_VERSION_LEGACY || sealed[0] === ENVELOPE_MAGIC;
1625
+ if (versioned && sealed.length > 26) {
1626
+ try {
1627
+ return xchacha20poly1305(key, sealed.slice(2, 26)).decrypt(sealed.slice(26));
1628
+ } catch {
1629
+ }
1630
+ }
1631
+ return xchacha20poly1305(key, sealed.slice(0, 24)).decrypt(sealed.slice(24));
1632
+ }
1633
+ var sealRaw = sealBytes;
1634
+ var openRaw = openSealed;
1635
+ function sealManifest(channelKey, manifest, version = KEY_VERSION_LEGACY) {
1636
+ return sealBytes(channelKey, version, new TextEncoder().encode(JSON.stringify(manifest)));
1637
+ }
1638
+ function openManifest(channelKey, sealed) {
1639
+ const m = JSON.parse(new TextDecoder().decode(openSealed(channelKey, sealed)));
1640
+ if (!Array.isArray(m.chunks)) m.chunks = [];
1641
+ return m;
1642
+ }
1643
+ function decryptChannelName(channelKey, sealed) {
1644
+ return new TextDecoder().decode(openSealed(channelKey, sealed));
1645
+ }
1646
+
1530
1647
  // src/shared/direct-fallback.ts
1531
1648
  function createFallbackMeta(file) {
1532
1649
  const key = crypto.getRandomValues(new Uint8Array(32));
@@ -2486,7 +2603,7 @@ async function sendDirect(ctx, input, report2, deps = {}) {
2486
2603
  const grant = await directGrantFor(ctx, input.channel);
2487
2604
  const key = channelKeyOf(ctx.identity, grant);
2488
2605
  const channelName = channelLabel(ctx, grant);
2489
- const file = await (deps.openFile ?? openAsBlob)(input.path).catch(() => {
2606
+ const file = await (deps.openFile ?? openAsBlob)(input.path, { type: mimeFor(input.path) }).catch(() => {
2490
2607
  throw new ZasError("upload_failed", 400);
2491
2608
  });
2492
2609
  if (file.size > DIRECT_FILE_MAX_BYTES) throw new ZasError("file_too_big", 413);
@@ -2498,7 +2615,7 @@ async function sendDirect(ctx, input, report2, deps = {}) {
2498
2615
  const stamp = { device, owner_uid: owner };
2499
2616
  const startedAt = now();
2500
2617
  report2("offer");
2501
- const meta = { name, size: file.size, mime: mimeFor(input.path) };
2618
+ const meta = directMetaOf(file, name);
2502
2619
  const { id } = await ctx.client.api("POST", `/direct/${cid}`, {
2503
2620
  meta_enc: seal(meta),
2504
2621
  key_version: grant.key_version,
@@ -2560,6 +2677,7 @@ async function sendDirect(ctx, input, report2, deps = {}) {
2560
2677
  },
2561
2678
  onDiag: (d) => {
2562
2679
  diag = d;
2680
+ deps.onDiag?.(d);
2563
2681
  }
2564
2682
  });
2565
2683
  let over = false;
@@ -3144,6 +3262,7 @@ async function receiveDirect(ctx, input, report2, deps = {}) {
3144
3262
  },
3145
3263
  onDiag: (d) => {
3146
3264
  diag = d;
3265
+ deps.onDiag?.(d);
3147
3266
  },
3148
3267
  sink: async (m) => {
3149
3268
  sink.open(m);
@@ -3292,7 +3411,10 @@ var JobRunner = class {
3292
3411
  const report2 = (phase) => {
3293
3412
  if (job.status === "running") job.phase = phase;
3294
3413
  };
3295
- this.settled.set(job, work(report2).then(
3414
+ const note = (diag) => {
3415
+ job.diag = diag;
3416
+ };
3417
+ this.settled.set(job, work(report2, note).then(
3296
3418
  (result) => {
3297
3419
  job.status = "done";
3298
3420
  job.result = result;
@@ -3806,7 +3928,7 @@ async function report(client, properties) {
3806
3928
 
3807
3929
  // src/server.ts
3808
3930
  function agentVersion() {
3809
- return true ? "0.6.0" : "0.0.0-dev";
3931
+ return true ? "0.6.2" : "0.0.0-dev";
3810
3932
  }
3811
3933
  var AGENT_CUE = " The owner sees every item this agent sends with the >_ agent mark and this agent's name, on every device.";
3812
3934
  var PAIR_ANNOUNCE_MS = 15e3;
@@ -3866,10 +3988,17 @@ function buildServer(profile, deps = {}) {
3866
3988
  return { isError: true, ...text2(`${err.code}: ${humanSentence(err)}`) };
3867
3989
  };
3868
3990
  const settled = (job) => {
3869
- if (job.status === "done") return text2(job.result);
3991
+ if (job.status === "done") {
3992
+ return job.diag ? text2({ ...job.result, diag: formatDirectDiagLines(job.diag) }) : text2(job.result);
3993
+ }
3870
3994
  if (job.status === "failed") {
3871
3995
  const e = job.error;
3872
- return failed(e ? new ZasError(e.code, e.status, e.message, e.retryAfterMs, e.serverCode) : new ZasError("internal", 0));
3996
+ const answer = failed(e ? new ZasError(e.code, e.status, e.message, e.retryAfterMs, e.serverCode) : new ZasError("internal", 0));
3997
+ if (!job.diag) return answer;
3998
+ const said = answer.content[0].text;
3999
+ return { isError: true, ...text2(`${said}
4000
+
4001
+ ${formatDirectDiagLines(job.diag).join("\n")}`) };
3873
4002
  }
3874
4003
  return text2({ job_id: job.id, status: "running", phase: job.phase });
3875
4004
  };
@@ -4015,9 +4144,10 @@ function buildServer(profile, deps = {}) {
4015
4144
  "direct",
4016
4145
  input.path,
4017
4146
  input.channel ?? "",
4018
- (report2) => sendDirect(c, input, report2, {
4147
+ (report2, note) => sendDirect(c, input, report2, {
4019
4148
  ...deps.direct ?? {},
4020
- onFailed: (record) => rememberFailed(job.id, record)
4149
+ onFailed: (record) => rememberFailed(job.id, record),
4150
+ onDiag: note
4021
4151
  })
4022
4152
  );
4023
4153
  return settled(await runner.wait(job));
@@ -4067,9 +4197,10 @@ function buildServer(profile, deps = {}) {
4067
4197
  "receive",
4068
4198
  input.dest ?? "Directo",
4069
4199
  input.channel ?? "",
4070
- (report2) => receiveDirect(c, input, report2, {
4200
+ (report2, note) => receiveDirect(c, input, report2, {
4071
4201
  ...deps.receive ?? {},
4072
- onFailed: (record) => rememberFailedReceive(job.id, record)
4202
+ onFailed: (record) => rememberFailedReceive(job.id, record),
4203
+ onDiag: note
4073
4204
  })
4074
4205
  );
4075
4206
  return settled(await runner.wait(job));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zas-agent",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",