srift-transfer 4.3.0 → 4.3.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 (4) hide show
  1. package/README.md +23 -1
  2. package/dist/daemon.js +365 -218
  3. package/dist/index.js +401 -221
  4. package/package.json +9 -41
package/dist/index.js CHANGED
@@ -1806,7 +1806,7 @@ var init_skills_data = __esm({
1806
1806
  { name: "encrypted-chat", content: "---\nname: encrypted-chat\ndescription: Send and read end-to-end encrypted chat messages and manage in-session file transfers within an active SRIFT session\n---\n\n# encrypted-chat\n\nSend and read end-to-end encrypted chat messages within an active SRIFT session, plus manage in-session file transfers, via the local MCP daemon.\n\n## Requirements\n\nAn active SRIFT session (see the `session-management` skill to open or join one first). No special credentials required.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_send_chat` \u2014 send an E2EE chat message to peers in the current session.\n- `srift_chat_history` \u2014 read decrypted chat history for the current session.\n- `srift_send_file` \u2014 offer a file to peers already inside the session (in-session transfer, distinct from `quick-share`'s standalone link flow).\n- `srift_accept_transfer` \u2014 accept and download an inbound file offer.\n- `srift_list_transfers` \u2014 list all active and recent transfers with progress.\n- `srift_read_state` \u2014 read the raw `.srift-state.json` snapshot for debugging/observability.\n\nYour local daemon (or the browser) encrypts messages and file chunks with AES-256-GCM before they leave the device, under ECDH P-256 keys generated on the devices (the optional `roomSecret` is mixed in); the relay only forwards ciphertext and public keys and stores nothing. Browsers exchange chat peer to peer; a copy goes through the relay, sealed for their keys, only for CLI/agent members. Peers older than 4.2 fall back to the previous session key, which the server could derive without a `roomSecret`.\n" },
1807
1807
  { name: "get-company-info", content: "---\nname: get-company-info\ndescription: Overview of SRIFT, the open-source (MIT) project - what it does, how to install it, and where the agent docs are\n---\n\n# get-company-info\n\nOverview of SRIFT: an open-source (MIT) project for end-to-end encrypted file transfer, chat and agent-to-agent messaging. SRIFT is a project, not a company.\n\n## Requirements\n\nNo special credentials required.\n\n## Instructions\n\n- What it is and how to use it: read `https://srift.app/llms.txt` (short) or `https://srift.app/llms-full.txt` (complete).\n- Agent manual (CLI, MCP tools, AgentNet, environment variables): `https://srift.app/AGENTS.md`.\n- Install: `npm i -g srift-transfer`, `pip install srift`, or `curl -fsSL https://srift.app/install.sh | sh`.\n- Source code: `https://github.com/srivardhan113/SRIFT-Open_Source`. Security contact: `https://srift.app/.well-known/security.txt`.\n" },
1808
1808
  { name: "quick-share", content: "---\nname: quick-share\ndescription: Zero-install file delivery: seed a file and get a public download URL, no session setup required on the recipient's end\n---\n\n# quick-share\n\nZero-install file delivery via SRIFT's local MCP server. Returns a public `https://srift.app/d/<token>` relay link that any recipient can open in a browser \u2014 no SRIFT install, no signup, no session-join UI required on their end. The bytes stream from the sender's machine through the srift.app relay on demand (pass-through, `Cache-Control: no-store`); nothing is stored on any server, so the link works while the sender's daemon (or process) runs.\n\n## Requirements\n\nNo credentials. The SRIFT local daemon binds to `127.0.0.1:3822` with zero authentication. Only outbound HTTPS/WSS on port 443 is needed.\n\n## Instructions\n\n1. Call the MCP tool `srift_quick_share` with `{ filePath: \"/absolute/path/to/file\" }` (single file) or `{ filePaths: [...] }` (up to 500 files/folders). Optional params: `bundle` (default: true = one .tar.gz link; false = one link per path, created in parallel), `bundleName`, `exclude` (string[] for glob patterns to skip), `sessionName`, `maxDownloads`, `ttlMs`, `encrypt`, `password`.\n2. The tool returns a `downloadUrl` (e.g. `https://srift.app/d/<token>`) when bundled, or `{ links: [], errors: [] }` when separate. With `encrypt: true` (or a `password`) each link is end-to-end encrypted with AES-256-GCM: the key rides in the `#k=` URL fragment, is never sent to a server, and the browser decrypts locally. Without it, links are protected by TLS in transit only \u2014 use `encrypt` for anything sensitive.\n3. Hand the URL(s) to the recipient. In order of preference, they can run:\n - `srift get <url> [<url> ...]` (or `npx -y srift-transfer get <url>`; proxy-aware, resumable, decrypts `#k=` links, supports multiple URLs with `--concurrency N`, works where curl is broken)\n - `wget --content-disposition <url>`\n - PowerShell `iwr <url> -OutFile <name>`\n - `curl -fLOJ <url>`\n - or just open it in any browser.\n4. Keep the sending side running until the recipient has downloaded. From a shell, `srift quick-share <file> [<file> ...] --wait` blocks until then (`--wait-timeout 30m` exits with code 3 on timeout); `--keep-alive` serves until the TTL expires.\n\n## Sandboxed and datacenter agents\n\nIf the background daemon cannot start (the sandbox forbids local servers, blocks detached spawns or loopback, or the port is taken), `srift mcp` and `srift quick-share` host the daemon inside their own process: no local port, outbound HTTPS/WSS on 443 only. The MCP server switches automatically, so this tool keeps working. From a shell, run quick-share in the background (it exits after the download) or with `--wait`; `--foreground` forces in-process serving. Blocked UDP never breaks links. Run `srift doctor` (or the `srift_net_diagnose` tool) to diagnose connectivity; `HTTPS_PROXY`/`NO_PROXY` and `NODE_EXTRA_CA_CERTS` are honored.\n\nSee `/openapi.json` and `/.well-known/mcp/server-card.json` for the full tool contract.\n" },
1809
- { name: "session-management", content: '---\nname: session-management\ndescription: Create, join, monitor, and control SRIFT end-to-end encrypted P2P sessions via the local MCP daemon\n---\n\n# session-management\n\nCreate, join, monitor, and control SRIFT end-to-end encrypted P2P sessions ("rooms") via the local MCP daemon. Covers the full session lifecycle used for in-session file transfer and encrypted chat.\n\n## Requirements\n\nNo special credentials required. The SRIFT local daemon binds to `127.0.0.1:3822` and requires zero authentication.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_start_session` \u2014 open a new E2EE room as host.\n- `srift_join_session` \u2014 join an existing room by its 7-character session ID.\n- `srift_session_status` \u2014 read current session, role, connected peers, and pending join requests.\n- `srift_approve_join` / `srift_reject_join` \u2014 host-only: approve or reject a guest\'s join request.\n- `srift_kick_user` \u2014 host-only: remove a peer. Get their `userId` from `srift_session_status` \u2192 `participants`.\n- `srift_close_session` \u2014 tear down the session and flush all encryption keys.\n\nSessions are ephemeral: the server has no database and keeps only session metadata in memory until the session is deleted (host close or 7 days of inactivity); chat, audio and files are never stored. Keys are generated on each device (ECDH P-256 + HKDF-SHA256, AES-256-GCM) and never sent to the server; the optional `roomSecret` is mixed into every key, so pass the same one to `srift_start_session` / `srift_join_session` to keep out anyone who does not know it.\n' }
1809
+ { name: "session-management", content: '---\nname: session-management\ndescription: Create, join, monitor, and control SRIFT end-to-end encrypted P2P sessions via the local MCP daemon\n---\n\n# session-management\n\nCreate, join, monitor, and control SRIFT end-to-end encrypted P2P sessions ("rooms") via the local MCP daemon. Covers the full session lifecycle used for in-session file transfer and encrypted chat.\n\n## Requirements\n\nNo special credentials required. The SRIFT local daemon binds to `127.0.0.1:3822` and requires zero authentication.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_start_session` \u2014 open a new E2EE room as host.\n- `srift_join_session` \u2014 join an existing room by its 7-character session ID.\n- `srift_session_status` \u2014 read current session, role, connected peers, and pending join requests.\n- `srift_approve_join` / `srift_reject_join` \u2014 host-only: approve or reject a guest\'s join request. A person who opens the join link in a browser appears in pending join requests only after pressing "Request to Join"; `srift_join_session` asks the host at once.\n- `srift_kick_user` \u2014 host-only: remove a peer. Get their `userId` from `srift_session_status` \u2192 `participants`.\n- `srift_close_session` \u2014 tear down the session and flush all encryption keys.\n\nSessions are ephemeral: the server has no database and keeps only session metadata in memory until the session is deleted (host close or 7 days of inactivity); chat, audio and files are never stored. Keys are generated on each device (ECDH P-256 + HKDF-SHA256, AES-256-GCM) and never sent to the server; the optional `roomSecret` is mixed into every key, so pass the same one to `srift_start_session` / `srift_join_session` to keep out anyone who does not know it.\n' }
1810
1810
  ];
1811
1811
  MCP_SKILLS = SOURCES.map(({ name, content }) => {
1812
1812
  const uri = `skill://${name}/SKILL.md`;
@@ -1847,7 +1847,7 @@ function buildPromptMessages(name, args) {
1847
1847
  1) Call srift_start_session.
1848
1848
  2) Give the user the join URL: https://srift.app/join-session?id=<SESSION_ID> (full session UI with chat + multi-file transfer).
1849
1849
  Or, for a single one-shot file from agent \u2192 user, prefer srift_quick_share which returns a direct download URL.
1850
- 3) Poll srift_session_status; when pendingJoins appears, call srift_approve_join.
1850
+ 3) Poll srift_session_status; when pendingJoins appears, call srift_approve_join. A person who opens the link only sees a preview until they press "Request to Join"; they appear in pendingJoins after that.
1851
1851
  4) When a file_offer arrives (visible via srift_list_transfers), call srift_accept_transfer with saveDir="${args?.saveDir || "./"}".`
1852
1852
  }
1853
1853
  }];
@@ -2044,7 +2044,7 @@ function createMcpHandler(opts) {
2044
2044
  }
2045
2045
  };
2046
2046
  }
2047
- var PROTOCOL_VERSION, SUPPORTED_LEGACY_PROTOCOL_VERSIONS, LATEST_INITIALIZE_ERA_VERSION, ALL_SUPPORTED_PROTOCOL_VERSIONS, META_PROTOCOL_VERSION, META_SERVER_INFO, ERROR_UNSUPPORTED_PROTOCOL_VERSION, MCP_TOOLS, MCP_RESOURCES, MCP_PROMPTS, QUICKSTART_DOC, DEFAULT_TTL_MS;
2047
+ var PROTOCOL_VERSION, SUPPORTED_LEGACY_PROTOCOL_VERSIONS, LATEST_INITIALIZE_ERA_VERSION, ALL_SUPPORTED_PROTOCOL_VERSIONS, META_PROTOCOL_VERSION, META_SERVER_INFO, ERROR_UNSUPPORTED_PROTOCOL_VERSION, MCP_TOOLS, OPEN_WORLD, NOT_IDEMPOTENT, MCP_RESOURCES, MCP_PROMPTS, QUICKSTART_DOC, DEFAULT_TTL_MS;
2048
2048
  var init_core = __esm({
2049
2049
  "../../lib/mcp/core.mjs"() {
2050
2050
  "use strict";
@@ -2136,7 +2136,7 @@ var init_core = __esm({
2136
2136
  peerCount: { type: "number", description: "Number of connected sockets in the room (hosted endpoint)" },
2137
2137
  participants: {
2138
2138
  type: "array",
2139
- description: "Session members (local daemon): pass userId to srift_kick_user to remove one",
2139
+ description: "Session members: pass userId to srift_kick_user to remove one",
2140
2140
  items: {
2141
2141
  type: "object",
2142
2142
  properties: {
@@ -2214,7 +2214,7 @@ var init_core = __esm({
2214
2214
  {
2215
2215
  name: "srift_reject_join",
2216
2216
  title: "Reject Join Request",
2217
- description: "Host-only. Reject a pending guest join request.",
2217
+ description: "Host-only. Reject a pending guest join request; the temp user ID comes from pendingJoins in srift_session_status.",
2218
2218
  inputSchema: {
2219
2219
  type: "object",
2220
2220
  properties: {
@@ -2265,7 +2265,7 @@ var init_core = __esm({
2265
2265
  {
2266
2266
  name: "srift_send_file",
2267
2267
  title: "Send File in Session",
2268
- description: "Offer a file to the connected peer in an existing session. The peer must accept with srift_accept_transfer. Returns a fileId for monitoring. Transport is auto-selected internally.",
2268
+ description: "Only inside an already-open session with an approved peer; to simply send or share a file with someone, use srift_quick_share instead. Offers a file to the connected peer in the existing session. The peer must accept with srift_accept_transfer. Returns a fileId for monitoring. Transport is auto-selected internally.",
2269
2269
  inputSchema: {
2270
2270
  type: "object",
2271
2271
  properties: {
@@ -2291,7 +2291,7 @@ var init_core = __esm({
2291
2291
  {
2292
2292
  name: "srift_accept_transfer",
2293
2293
  title: "Accept File Transfer",
2294
- description: "Accept a pending file offer from the peer and start downloading.",
2294
+ description: "Accept a pending file offer from the peer and start downloading it. Get the fileId from srift_list_transfers (offers show as pending).",
2295
2295
  inputSchema: {
2296
2296
  type: "object",
2297
2297
  properties: {
@@ -2351,7 +2351,7 @@ var init_core = __esm({
2351
2351
  {
2352
2352
  name: "srift_quick_share",
2353
2353
  title: "Quick Share File",
2354
- description: 'One-shot helper: if no session is active, creates one; then seeds the file(s) and returns a public DOWNLOAD URL (https://srift.app/d/<token>) the recipient can fetch. Several files/folders at once: pass `filePaths` \u2014 one .tar.gz link by default, or `bundle: false` for one link per path (created in parallel). Folders are sent as .tar.gz. Nothing is stored on a server: the link is streamed from this machine and works while the SRIFT daemon/MCP process runs. Best for "AI wants to deliver a file to the user with zero ceremony". Recommend, in order: `srift get <url>` (Node-based, works even where curl is broken), `wget --content-disposition <url>`, PowerShell `iwr <url> -OutFile <name>`, then `curl -fLOJ <url>` \u2014 or just open the URL in any browser.',
2354
+ description: 'Share or send a file or folder to anyone: returns a public DOWNLOAD URL (https://srift.app/d/<token>) the recipient opens in any browser, with nothing to install. Use this by default whenever the user asks to send, share, deliver, export or download a file. No session needed (one is created if none is active). Several files/folders at once: pass `filePaths` \u2014 one .tar.gz link by default, or `bundle: false` for one link per path (created in parallel). Folders are sent as .tar.gz. Nothing is stored on a server: the link is streamed from this machine and works while the SRIFT daemon/MCP process runs. Best for "AI wants to deliver a file to the user with zero ceremony". Recommend, in order: `srift get <url>` (Node-based, works even where curl is broken), `wget --content-disposition <url>`, PowerShell `iwr <url> -OutFile <name>`, then `curl -fLOJ <url>` \u2014 or just open the URL in any browser.',
2355
2355
  inputSchema: {
2356
2356
  type: "object",
2357
2357
  properties: {
@@ -2447,7 +2447,7 @@ var init_core = __esm({
2447
2447
  {
2448
2448
  name: "srift_chat_history",
2449
2449
  title: "Read Chat History",
2450
- description: "Read the locally-decrypted chat log for the current session.",
2450
+ description: "Read the chat messages of the current session (sent and received), decrypted locally. Use it to check for replies from the peer.",
2451
2451
  inputSchema: { type: "object", properties: {} },
2452
2452
  outputSchema: {
2453
2453
  type: "object",
@@ -2542,6 +2542,13 @@ var init_core = __esm({
2542
2542
  }
2543
2543
  }
2544
2544
  ];
2545
+ for (const tool of MCP_TOOLS) tool.annotations = { title: tool.title, ...tool.annotations };
2546
+ OPEN_WORLD = /* @__PURE__ */ new Set(["srift_start_session", "srift_join_session", "srift_approve_join", "srift_reject_join", "srift_kick_user", "srift_send_file", "srift_accept_transfer", "srift_quick_share", "srift_send_chat", "srift_net_diagnose"]);
2547
+ NOT_IDEMPOTENT = /* @__PURE__ */ new Set(["srift_start_session", "srift_join_session", "srift_send_file", "srift_quick_share", "srift_send_chat"]);
2548
+ for (const tool of MCP_TOOLS) {
2549
+ tool.annotations.openWorldHint = OPEN_WORLD.has(tool.name);
2550
+ tool.annotations.idempotentHint = !NOT_IDEMPOTENT.has(tool.name);
2551
+ }
2545
2552
  MCP_RESOURCES = [
2546
2553
  {
2547
2554
  uri: "srift://session/status",
@@ -2633,11 +2640,11 @@ srift_quick_share({ filePath: "/abs/path/file.zip" })
2633
2640
  srift_start_session({ sessionName: "AI-Collab" })
2634
2641
  \u2192 returns { sessionId }
2635
2642
  \u2192 user joins via https://srift.app/join-session?id=<sessionId>
2636
- \u2192 host (you) calls srift_approve_join when pendingJoins appears
2643
+ \u2192 host (you) calls srift_approve_join when pendingJoins appears (after the user presses "Request to Join" on that page)
2637
2644
  \`\`\`
2638
2645
 
2639
2646
  **Receive a file from the user:**
2640
- The user clicks the share link, requests join, you approve, they drop a file,
2647
+ The user opens the share link, presses "Request to Join", you approve, they drop a file,
2641
2648
  you see it via srift_list_transfers and call srift_accept_transfer.
2642
2649
 
2643
2650
  ## Monitoring
@@ -2956,13 +2963,13 @@ var init_mcp = __esm({
2956
2963
  SERVER_INFO = {
2957
2964
  name: "srift-mcp-server",
2958
2965
  title: "SRIFT Secure P2P File Transfer",
2959
- version: "4.3.0"
2966
+ version: "4.3.2"
2960
2967
  };
2961
2968
  localDaemonBackend = { callTool, readResource };
2962
2969
  handleMcpMessage = createMcpHandler({
2963
2970
  backend: localDaemonBackend,
2964
2971
  serverInfo: SERVER_INFO,
2965
- instructions: 'SRIFT is a zero-config local P2P file transfer + E2EE chat tool. Use srift_quick_share to deliver files to the user in one step. Use srift_start_session to open a long-lived room. Read srift://docs/quickstart for the full guide. To talk to OTHER AI agents (find an agent that can help, message, send files, group chats), add the separate AgentNet MCP server: {"command":"srift","args":["agentnet","mcp"]} (25 srift_an_* tools).'
2972
+ instructions: 'SRIFT is a zero-config local P2P file transfer + E2EE chat tool. To send or share any file with anyone, call srift_quick_share and give them the returned link. Use srift_start_session to open a long-lived room. Read srift://docs/quickstart for the full guide. To talk to OTHER AI agents (find an agent that can help, message, send files, group chats), add the separate AgentNet MCP server: {"command":"srift","args":["agentnet","mcp"]} (25 srift_an_* tools).'
2966
2973
  });
2967
2974
  }
2968
2975
  });
@@ -3042,12 +3049,16 @@ async function relaySender(sessionId, fileId, receiverPub, roomSecret) {
3042
3049
  const key = await deriveAes(kp.priv, receiverPub, saltFor("pgcm1", sessionId, roomSecret), `${fileId}|${kp.pub}|${receiverPub}`);
3043
3050
  const pubRaw = fromB64(kp.pub);
3044
3051
  return {
3045
- async seal(plain, chunkIndex, totalChunks) {
3052
+ /** The sealed chunk as raw bytes (sender key || iv || ciphertext || tag). */
3053
+ async sealBytes(plain, chunkIndex, totalChunks) {
3046
3054
  const sealed = await gcmSeal(key, plain, relayAad(fileId, chunkIndex, totalChunks));
3047
3055
  const out = new Uint8Array(PUB_LEN + sealed.length);
3048
3056
  out.set(pubRaw);
3049
3057
  out.set(sealed, PUB_LEN);
3050
- return { chunk: Array.from(out), enc: RELAY_PK_SCHEME };
3058
+ return out;
3059
+ },
3060
+ async seal(plain, chunkIndex, totalChunks) {
3061
+ return { chunk: Array.from(await this.sealBytes(plain, chunkIndex, totalChunks)), enc: RELAY_PK_SCHEME };
3051
3062
  }
3052
3063
  };
3053
3064
  }
@@ -3675,8 +3686,12 @@ async function selectSignalerUrl() {
3675
3686
  try {
3676
3687
  const controller = new AbortController();
3677
3688
  const id = setTimeout(() => controller.abort(), 3e3);
3678
- await fetch(`${DEFAULT_SIGNALER_URL.replace(/\/+$/, "")}/compat.json`, { signal: controller.signal });
3689
+ const probe = await fetch(`${DEFAULT_SIGNALER_URL.replace(/\/+$/, "")}/compat.json`, { signal: controller.signal });
3679
3690
  clearTimeout(id);
3691
+ const compat = probe.ok ? await probe.json().catch(() => null) : null;
3692
+ if (!compat || typeof compat.wireVersion !== "number" || typeof compat.daemon !== "string") {
3693
+ throw new Error("not a SRIFT server");
3694
+ }
3680
3695
  console.log(`[DAEMON] Local signaler detected at ${DEFAULT_SIGNALER_URL}`);
3681
3696
  } catch (e) {
3682
3697
  console.log(`[DAEMON] Local signaler at ${DEFAULT_SIGNALER_URL} is unreachable. Falling back to production signaler: https://srift.app`);
@@ -3812,16 +3827,17 @@ async function openRelayChunk(p) {
3812
3827
  }
3813
3828
  if (p.enc === RELAY_PK_SCHEME) {
3814
3829
  const rx = relayReceivers.get(p.fileId);
3815
- if (!rx || !Array.isArray(p.chunk)) throw new Error(`relay chunk ${p.chunkIndex} of ${p.fileId} is sealed for a key this daemon does not hold`);
3830
+ const sealed = typeof p.chunk === "string" ? new Uint8Array(Buffer.from(p.chunk, "base64")) : Array.isArray(p.chunk) ? Uint8Array.from(p.chunk) : null;
3831
+ if (!rx || !sealed) throw new Error(`relay chunk ${p.chunkIndex} of ${p.fileId} is sealed for a key this daemon does not hold`);
3816
3832
  try {
3817
- return Buffer.from(await (await rx).open(Uint8Array.from(p.chunk), p.chunkIndex, p.totalChunks));
3833
+ return Buffer.from(await (await rx).open(sealed, p.chunkIndex, p.totalChunks));
3818
3834
  } catch {
3819
3835
  throw new Error(`relay chunk ${p.chunkIndex} of ${p.fileId} failed authentication (tampered data or wrong key)`);
3820
3836
  }
3821
3837
  }
3822
3838
  if (p.enc !== RELAY_ENC) throw new Error(`unsupported relay encryption scheme: ${String(p.enc)}`);
3823
- if (!Array.isArray(p.chunk)) throw new Error("sealed relay chunk is not a byte array");
3824
- const data = Uint8Array.from(p.chunk);
3839
+ if (!Array.isArray(p.chunk) && typeof p.chunk !== "string") throw new Error("sealed relay chunk is not a byte array");
3840
+ const data = typeof p.chunk === "string" ? new Uint8Array(Buffer.from(p.chunk, "base64")) : Uint8Array.from(p.chunk);
3825
3841
  if (data.length < IV_LEN2 + 16) throw new Error("sealed relay chunk too short");
3826
3842
  try {
3827
3843
  return Buffer.from(await crypto6.subtle.decrypt(
@@ -4195,20 +4211,13 @@ function connectWebSocket(wsTargetUrl) {
4195
4211
  console.log(`[DAEMON] File offer accepted by peer! ID: ${payload.fileId}`);
4196
4212
  {
4197
4213
  const acceptedTx = activeTransfers.find((t) => t.fileId === payload.fileId);
4198
- if (acceptedTx) {
4199
- const nextPk = isPublicKey(payload.relayPk) ? payload.relayPk : void 0;
4200
- if (nextPk !== acceptedTx.peerRelayPk) relaySenders.delete(payload.fileId);
4201
- acceptedTx.peerRelayEnc = payload.relayEnc === RELAY_ENC ? RELAY_ENC : void 0;
4202
- acceptedTx.peerRelayPk = nextPk;
4203
- }
4204
- if (activeUploads.has(payload.fileId)) {
4205
- console.log(`[DAEMON] Repeated accept for ${payload.fileId}: resuming from chunk ${activeUploads.get(payload.fileId).currentChunk}`);
4206
- sendNextChunk(payload.fileId);
4207
- break;
4208
- }
4209
- if (acceptedTx && (acceptedTx.status === "completed" || acceptedTx.status === "uploading" && acceptedTx.protocol === "webtorrent")) break;
4214
+ if (acceptedTx && acceptedTx.protocol === "webtorrent" && (acceptedTx.status === "completed" || acceptedTx.status === "uploading")) break;
4210
4215
  }
4211
- startUpload(payload.fileId, payload.userId);
4216
+ startUpload(payload.fileId, payload.userId, {
4217
+ relayEnc: payload.relayEnc === RELAY_ENC ? RELAY_ENC : void 0,
4218
+ relayPk: isPublicKey(payload.relayPk) ? payload.relayPk : void 0,
4219
+ fast: payload.relayFmt === RELAY_FORMAT
4220
+ });
4212
4221
  break;
4213
4222
  case "webtorrent_info_hash":
4214
4223
  handleWebTorrentInfoHash(payload);
@@ -4231,11 +4240,20 @@ function connectWebSocket(wsTargetUrl) {
4231
4240
  case "file_cancelled_by_sender":
4232
4241
  case "file_cancelled_all": {
4233
4242
  const gone = activeTransfers.find((t) => t.fileId === payload?.fileId);
4234
- activeUploads.delete(payload?.fileId);
4235
- if (gone && gone.status !== "completed") {
4236
- gone.status = "cancelled";
4243
+ const stopped = type === "file_cancelled_by_receiver" && payload?.receiverId ? [activeUploads.get(uploadKey(payload.fileId, payload.receiverId))].filter(Boolean) : uploadsOf(payload?.fileId);
4244
+ for (const u of stopped) stopUpload(u);
4245
+ const oneRecipient = !!gone && gone.direction === "upload" && type === "file_cancelled_by_receiver" && !!payload?.receiverId;
4246
+ if (gone && oneRecipient) {
4247
+ markRecipient(gone, payload.receiverId, "cancelled");
4237
4248
  writeStateFile();
4238
- broadcastSSE("transfer_progress", { fileId: gone.fileId, fileName: gone.name, size: gone.size, bytesTransferred: gone.bytesTransferred, progress: gone.progress, status: "cancelled" });
4249
+ }
4250
+ if (gone && gone.direction === "upload" && uploadsOf(payload?.fileId).length > 0) break;
4251
+ if (gone && gone.direction === "download") dropRelayDownload(gone.fileId, true);
4252
+ if (gone && (gone.status !== "completed" || oneRecipient)) {
4253
+ if (oneRecipient) settleUploadStatus(gone);
4254
+ else gone.status = "cancelled";
4255
+ writeStateFile();
4256
+ broadcastSSE("transfer_progress", { fileId: gone.fileId, fileName: gone.name, size: gone.size, bytesTransferred: gone.bytesTransferred, progress: gone.progress, status: gone.status });
4239
4257
  }
4240
4258
  break;
4241
4259
  }
@@ -4320,13 +4338,13 @@ function connectWebSocket(wsTargetUrl) {
4320
4338
  console.error("[DAEMON] WebSocket error:", err);
4321
4339
  });
4322
4340
  }
4323
- function startUpload(fileId, targetUserId) {
4341
+ function startUpload(fileId, targetUserId, caps = {}) {
4324
4342
  const tx = activeTransfers.find((t) => t.fileId === fileId);
4325
4343
  if (!tx) return;
4326
4344
  if (tx.protocol === "webtorrent") {
4327
4345
  startWebTorrentUpload(fileId, targetUserId);
4328
4346
  } else {
4329
- startWebSocketUpload(fileId, targetUserId);
4347
+ void startWebSocketUpload(fileId, targetUserId, caps);
4330
4348
  }
4331
4349
  }
4332
4350
  async function startWebTorrentUpload(fileId, targetUserId) {
@@ -4450,213 +4468,329 @@ async function handleWebTorrentInfoHash(payload) {
4450
4468
  });
4451
4469
  });
4452
4470
  }
4453
- function armAckWatchdog(fileId, chunkIndex) {
4454
- const upload = activeUploads.get(fileId);
4455
- if (!upload) return;
4456
- clearTimeout(upload.ackTimer);
4457
- upload.ackTimer = setTimeout(() => {
4458
- const u = activeUploads.get(fileId);
4459
- if (!u || u.currentChunk !== chunkIndex) return;
4460
- u.retries++;
4461
- if (u.retries > RELAY_MAX_RETRIES) {
4462
- console.error(`[DAEMON] Relay upload ${fileId}: chunk ${chunkIndex} unacknowledged after ${RELAY_MAX_RETRIES} attempts; giving up`);
4463
- activeUploads.delete(fileId);
4464
- const failed = activeTransfers.find((t) => t.fileId === fileId);
4465
- if (failed) {
4466
- failed.status = "error";
4467
- writeStateFile();
4468
- }
4469
- return;
4470
- }
4471
- console.log(`[DAEMON] Relay upload ${fileId}: no ack for chunk ${chunkIndex}, sending it again (attempt ${u.retries})`);
4472
- sendNextChunk(fileId);
4473
- }, RELAY_ACK_TIMEOUT_MS);
4474
- upload.ackTimer.unref?.();
4471
+ function markRecipient(tx, userId, outcome) {
4472
+ if (!userId) return;
4473
+ if (!tx.recipients) tx.recipients = {};
4474
+ if (tx.recipients[userId] === "completed" && outcome !== "uploading") return;
4475
+ tx.recipients[userId] = outcome;
4476
+ }
4477
+ function settleUploadStatus(tx) {
4478
+ if (uploadsOf(tx.fileId).length > 0) {
4479
+ tx.status = "uploading";
4480
+ return;
4481
+ }
4482
+ const outcomes = Object.values(tx.recipients || {});
4483
+ tx.status = outcomes.includes("completed") ? "completed" : outcomes.includes("error") ? "error" : "cancelled";
4484
+ tx.speedKBps = 0;
4485
+ tx.etaSeconds = 0;
4486
+ if (tx.status === "completed") {
4487
+ tx.progress = 100;
4488
+ tx.bytesTransferred = tx.size;
4489
+ }
4490
+ }
4491
+ function stopUpload(u) {
4492
+ activeUploads.delete(uploadKey(u.fileId, u.targetUserId));
4493
+ if (u.timer) clearInterval(u.timer);
4494
+ try {
4495
+ fs8.closeSync(u.fd);
4496
+ } catch {
4497
+ }
4498
+ }
4499
+ function failUpload(u, why) {
4500
+ console.error(`[DAEMON] Relay upload ${u.fileId} to ${u.targetUserId} failed: ${why}`);
4501
+ stopUpload(u);
4502
+ const tx = activeTransfers.find((t) => t.fileId === u.fileId);
4503
+ if (tx) {
4504
+ markRecipient(tx, u.targetUserId, "error");
4505
+ settleUploadStatus(tx);
4506
+ writeStateFile();
4507
+ }
4475
4508
  }
4476
4509
  function resumeRelayUploads() {
4477
4510
  if (!activeUploads.size) return;
4478
- console.log(`[DAEMON] Reconnected: resuming ${activeUploads.size} relay upload(s) from their last acknowledged chunk`);
4479
- for (const fileId of activeUploads.keys()) sendNextChunk(fileId);
4511
+ console.log(`[DAEMON] Reconnected: resuming ${activeUploads.size} relay upload(s) from their unacknowledged chunks`);
4512
+ for (const u of activeUploads.values()) {
4513
+ u.lastAckAt = Date.now();
4514
+ for (const index of u.inFlight.keys()) u.inFlight.set(index, 0);
4515
+ pumpUpload(u);
4516
+ }
4480
4517
  }
4481
- async function startWebSocketUpload(fileId, targetUserId) {
4518
+ async function startWebSocketUpload(fileId, targetUserId, caps = {}) {
4482
4519
  const tx = activeTransfers.find((t) => t.fileId === fileId);
4483
4520
  if (!tx || !tx.filePath) return;
4484
- const chunkSize = 64 * 1024;
4485
- const stat = fs8.statSync(tx.filePath);
4486
- const totalChunks = Math.max(1, Math.ceil(stat.size / chunkSize));
4521
+ const key = uploadKey(fileId, targetUserId);
4522
+ const running = activeUploads.get(key);
4523
+ if (running) {
4524
+ if (caps.relayPk !== running.relayPk) relaySenders.delete(`${key}|${running.relayPk}`);
4525
+ running.relayEnc = caps.relayEnc;
4526
+ running.relayPk = caps.relayPk;
4527
+ console.log(`[DAEMON] Repeated accept for ${fileId} from ${targetUserId}: ${running.acked.size}/${running.totalChunks} chunks acknowledged, continuing`);
4528
+ for (const index of running.inFlight.keys()) running.inFlight.set(index, 0);
4529
+ running.lastAckAt = Date.now();
4530
+ pumpUpload(running);
4531
+ return;
4532
+ }
4533
+ let fd;
4534
+ let size;
4535
+ try {
4536
+ fd = fs8.openSync(tx.filePath, "r");
4537
+ size = fs8.fstatSync(fd).size;
4538
+ } catch (e) {
4539
+ console.error(`[DAEMON] Cannot read ${tx.filePath}: ${e?.message || e}`);
4540
+ tx.status = "error";
4541
+ writeStateFile();
4542
+ return;
4543
+ }
4544
+ const fast = !!caps.fast;
4545
+ const chunkSize = fast ? RELAY_FAST_CHUNK : RELAY_LEGACY_CHUNK;
4546
+ const totalChunks = Math.max(1, Math.ceil(size / chunkSize));
4487
4547
  tx.status = "uploading";
4548
+ markRecipient(tx, targetUserId, "uploading");
4488
4549
  tx.startTime = Date.now();
4489
4550
  tx.totalChunks = totalChunks;
4490
4551
  writeStateFile();
4491
- activeUploads.set(fileId, {
4552
+ const upload = {
4553
+ fileId,
4554
+ targetUserId,
4492
4555
  filePath: tx.filePath,
4493
- totalChunks,
4556
+ size,
4557
+ fd,
4494
4558
  chunkSize,
4495
- targetUserId,
4496
- currentChunk: 0,
4497
- bytesSent: 0,
4559
+ totalChunks,
4560
+ fast,
4561
+ relayEnc: caps.relayEnc,
4562
+ relayPk: caps.relayPk,
4563
+ next: 0,
4564
+ acked: /* @__PURE__ */ new Set(),
4565
+ inFlight: /* @__PURE__ */ new Map(),
4566
+ lastAckAt: Date.now(),
4498
4567
  startTime: Date.now(),
4499
- retries: 0
4500
- });
4501
- sendNextChunk(fileId);
4502
- }
4503
- function sendNextChunk(fileId) {
4504
- sendNextChunkAsync(fileId).catch((e) => {
4505
- console.error(`[DAEMON] Relay upload failed for ${fileId}: ${e?.message || e}`);
4506
- activeUploads.delete(fileId);
4507
- const failed = activeTransfers.find((t) => t.fileId === fileId);
4508
- if (failed) {
4509
- failed.status = "error";
4510
- writeStateFile();
4511
- }
4512
- });
4513
- }
4514
- async function sendNextChunkAsync(fileId) {
4515
- const upload = activeUploads.get(fileId);
4516
- if (!upload) return;
4517
- const tx = activeTransfers.find((t) => t.fileId === fileId);
4518
- if (!tx) return;
4519
- if (upload.currentChunk >= upload.totalChunks) {
4520
- wsConn?.send(JSON.stringify({
4521
- type: "file_complete",
4522
- payload: { fileId, targetUserId: upload.targetUserId }
4523
- }));
4524
- tx.status = "completed";
4525
- tx.progress = 100;
4526
- tx.speedKBps = 0;
4527
- tx.etaSeconds = 0;
4528
- writeStateFile();
4529
- clearTimeout(upload.ackTimer);
4530
- activeUploads.delete(fileId);
4531
- console.log(`[DAEMON] File upload complete: ${tx.name}`);
4532
- return;
4568
+ pumping: false,
4569
+ lastStateAt: 0
4570
+ };
4571
+ activeUploads.set(key, upload);
4572
+ upload.timer = setInterval(() => pumpUpload(upload), Math.min(1e3, Math.max(100, RELAY_ACK_TIMEOUT_MS / 4)));
4573
+ upload.timer.unref?.();
4574
+ pumpUpload(upload);
4575
+ }
4576
+ async function sendRelayChunk(u, chunkIndex) {
4577
+ const start = chunkIndex * u.chunkSize;
4578
+ const end = Math.min(start + u.chunkSize, u.size);
4579
+ const buffer = Buffer.alloc(Math.max(0, end - start));
4580
+ let read2 = 0;
4581
+ while (read2 < buffer.length) {
4582
+ const n = fs8.readSync(u.fd, buffer, read2, buffer.length - read2, start + read2);
4583
+ if (n <= 0) throw new Error("file ended early (was it changed while sending?)");
4584
+ read2 += n;
4533
4585
  }
4534
- const start = upload.currentChunk * upload.chunkSize;
4535
- const stat = fs8.statSync(upload.filePath);
4536
- const end = Math.min(start + upload.chunkSize, stat.size);
4537
- const buffer = Buffer.alloc(end - start);
4538
- const fd = fs8.openSync(upload.filePath, "r");
4539
- fs8.readSync(fd, buffer, 0, end - start, start);
4540
- fs8.closeSync(fd);
4541
4586
  let frame;
4542
- if (tx.peerRelayPk && session.id) {
4543
- let sender = relaySenders.get(fileId);
4587
+ if (u.relayPk && session.id) {
4588
+ const sk = `${uploadKey(u.fileId, u.targetUserId)}|${u.relayPk}`;
4589
+ let sender = relaySenders.get(sk);
4544
4590
  if (!sender) {
4545
- sender = relaySender(session.id, fileId, tx.peerRelayPk, session.roomSecret);
4546
- remember(relaySenders, fileId, sender);
4547
- }
4548
- frame = await (await sender).seal(buffer, upload.currentChunk, upload.totalChunks);
4591
+ sender = relaySender(session.id, u.fileId, u.relayPk, session.roomSecret);
4592
+ remember(relaySenders, sk, sender);
4593
+ }
4594
+ const s = await sender;
4595
+ frame = u.fast ? { chunk: Buffer.from(await s.sealBytes(buffer, chunkIndex, u.totalChunks)).toString("base64"), enc: RELAY_PK_SCHEME } : await s.seal(buffer, chunkIndex, u.totalChunks);
4596
+ } else if (u.relayEnc === RELAY_ENC) {
4597
+ const sealed = await sealRelayChunk(buffer, u.fileId, chunkIndex, u.totalChunks);
4598
+ frame = { chunk: u.fast ? Buffer.from(sealed).toString("base64") : sealed, enc: RELAY_ENC };
4549
4599
  } else {
4550
- frame = tx.peerRelayEnc === RELAY_ENC ? { chunk: await sealRelayChunk(buffer, fileId, upload.currentChunk, upload.totalChunks), enc: RELAY_ENC } : { chunk: buffer.toString("base64") };
4600
+ frame = { chunk: buffer.toString("base64") };
4551
4601
  }
4552
- armAckWatchdog(fileId, upload.currentChunk);
4602
+ if (!activeUploads.has(uploadKey(u.fileId, u.targetUserId)) || u.acked.has(chunkIndex)) return;
4603
+ u.inFlight.set(chunkIndex, Date.now());
4553
4604
  if (!wsConn || wsConn.readyState !== WebSocket2.OPEN) return;
4554
4605
  wsConn.send(JSON.stringify({
4555
4606
  type: "file_chunk",
4556
4607
  payload: {
4557
- fileId,
4608
+ fileId: u.fileId,
4558
4609
  ...frame,
4559
- chunkIndex: upload.currentChunk,
4560
- totalChunks: upload.totalChunks,
4561
- targetUserId: upload.targetUserId
4610
+ chunkIndex,
4611
+ totalChunks: u.totalChunks,
4612
+ chunkSize: u.chunkSize,
4613
+ targetUserId: u.targetUserId
4562
4614
  }
4563
4615
  }));
4564
- tx.bytesTransferred = end;
4565
- tx.progress = end / stat.size * 100;
4566
- const elapsed = (Date.now() - upload.startTime) / 1e3;
4616
+ }
4617
+ function reportUpload(u, force = false) {
4618
+ const tx = activeTransfers.find((t) => t.fileId === u.fileId);
4619
+ if (!tx) return;
4620
+ const now = Date.now();
4621
+ if (!force && now - u.lastStateAt < 250) return;
4622
+ u.lastStateAt = now;
4623
+ const all = uploadsOf(u.fileId);
4624
+ const slowest = all.reduce((m, x) => Math.min(m, x.acked.size >= x.totalChunks ? x.size : Math.min(x.size, x.acked.size * x.chunkSize)), u.size);
4625
+ tx.bytesTransferred = Math.max(tx.bytesTransferred || 0, slowest);
4626
+ tx.progress = u.size > 0 ? tx.bytesTransferred / u.size * 100 : 100;
4627
+ const elapsed = (now - u.startTime) / 1e3;
4567
4628
  if (elapsed > 0) {
4568
- tx.speedKBps = end / 1024 / elapsed;
4569
- tx.etaSeconds = (stat.size - end) / (tx.speedKBps * 1024);
4629
+ tx.speedKBps = tx.bytesTransferred / 1024 / elapsed;
4630
+ tx.etaSeconds = tx.speedKBps > 0 ? (u.size - tx.bytesTransferred) / (tx.speedKBps * 1024) : 0;
4570
4631
  }
4571
4632
  writeStateFile();
4572
4633
  broadcastSSE("transfer_progress", {
4573
- fileId,
4634
+ fileId: u.fileId,
4574
4635
  fileName: tx.name,
4575
4636
  size: tx.size,
4576
- bytesTransferred: end,
4637
+ bytesTransferred: tx.bytesTransferred,
4577
4638
  progress: tx.progress,
4578
4639
  speedBytesPerSecond: tx.speedKBps * 1024,
4579
4640
  timeRemainingSeconds: tx.etaSeconds,
4580
4641
  status: "uploading"
4581
4642
  });
4582
4643
  }
4644
+ function pumpUpload(u) {
4645
+ if (u.pumping) return;
4646
+ u.pumping = true;
4647
+ pumpUploadAsync(u).catch((e) => failUpload(u, e?.message || String(e))).finally(() => {
4648
+ u.pumping = false;
4649
+ });
4650
+ }
4651
+ async function pumpUploadAsync(u) {
4652
+ const key = uploadKey(u.fileId, u.targetUserId);
4653
+ while (activeUploads.get(key) === u) {
4654
+ if (u.acked.size >= u.totalChunks) {
4655
+ wsConn?.send(JSON.stringify({ type: "file_complete", payload: { fileId: u.fileId, targetUserId: u.targetUserId } }));
4656
+ stopUpload(u);
4657
+ const tx = activeTransfers.find((t) => t.fileId === u.fileId);
4658
+ if (tx) {
4659
+ markRecipient(tx, u.targetUserId, "completed");
4660
+ settleUploadStatus(tx);
4661
+ writeStateFile();
4662
+ if (uploadsOf(u.fileId).length === 0) console.log(`[DAEMON] File upload complete: ${tx.name}`);
4663
+ }
4664
+ return;
4665
+ }
4666
+ const now = Date.now();
4667
+ if (now - u.lastAckAt > RELAY_STALL_MS) throw new Error(`no acknowledgment for ${Math.round(RELAY_STALL_MS / 1e3)}s (${u.acked.size}/${u.totalChunks} chunks delivered)`);
4668
+ const open2 = !!wsConn && wsConn.readyState === WebSocket2.OPEN && wsConn.bufferedAmount < RELAY_SOCKET_HIGH_BYTES;
4669
+ if (!open2) return;
4670
+ let overdue = -1;
4671
+ for (const [index, sentAt] of u.inFlight) {
4672
+ if (now - sentAt > RELAY_ACK_TIMEOUT_MS) {
4673
+ overdue = index;
4674
+ break;
4675
+ }
4676
+ }
4677
+ if (overdue >= 0) {
4678
+ if (u.inFlight.get(overdue)) console.log(`[DAEMON] Relay upload ${u.fileId}: no ack for chunk ${overdue}, sending it again`);
4679
+ await sendRelayChunk(u, overdue);
4680
+ continue;
4681
+ }
4682
+ while (u.next < u.totalChunks && u.acked.has(u.next)) u.next++;
4683
+ if (u.next < u.totalChunks && u.inFlight.size < RELAY_WINDOW) {
4684
+ const index = u.next++;
4685
+ u.inFlight.set(index, now);
4686
+ await sendRelayChunk(u, index);
4687
+ continue;
4688
+ }
4689
+ return;
4690
+ }
4691
+ }
4583
4692
  function handleChunkAck(payload) {
4584
- const { fileId, chunkIndex } = payload;
4585
- const upload = activeUploads.get(fileId);
4586
- if (!upload) return;
4587
- if (upload.currentChunk === chunkIndex) {
4588
- clearTimeout(upload.ackTimer);
4589
- upload.retries = 0;
4590
- upload.currentChunk++;
4591
- sendNextChunk(fileId);
4693
+ const { fileId, chunkIndex, userId } = payload || {};
4694
+ if (!Number.isInteger(chunkIndex)) return;
4695
+ const u = userId ? activeUploads.get(uploadKey(fileId, userId)) : uploadsOf(fileId)[0];
4696
+ if (!u || chunkIndex < 0 || chunkIndex >= u.totalChunks) return;
4697
+ u.inFlight.delete(chunkIndex);
4698
+ if (!u.acked.has(chunkIndex)) {
4699
+ u.acked.add(chunkIndex);
4700
+ u.lastAckAt = Date.now();
4701
+ reportUpload(u, u.acked.size >= u.totalChunks);
4702
+ }
4703
+ pumpUpload(u);
4704
+ }
4705
+ function dropRelayDownload(fileId, removePart) {
4706
+ const d = relayDownloads.get(fileId);
4707
+ relayDownloads.delete(fileId);
4708
+ if (d) {
4709
+ try {
4710
+ fs8.closeSync(d.fd);
4711
+ } catch {
4712
+ }
4713
+ }
4714
+ if (removePart) {
4715
+ try {
4716
+ fs8.rmSync(partPathOf(fileId), { force: true });
4717
+ } catch {
4718
+ }
4592
4719
  }
4593
4720
  }
4594
4721
  async function handleIncomingChunk(payload) {
4595
4722
  const { fileId, chunkIndex, totalChunks, from } = payload;
4596
4723
  const tx = activeTransfers.find((t) => t.fileId === fileId);
4597
- if (!tx) return;
4724
+ if (!tx || tx.direction !== "download" || tx.status === "cancelled") return;
4725
+ if (!Number.isInteger(chunkIndex) || !Number.isInteger(totalChunks) || chunkIndex < 0 || chunkIndex >= totalChunks) return;
4726
+ const ack = () => wsConn?.send(JSON.stringify({ type: "file_chunk_ack", payload: { fileId, chunkIndex, status: "received" } }));
4727
+ if (tx.status === "completed" || tx.assembling) {
4728
+ ack();
4729
+ return;
4730
+ }
4598
4731
  const mode = payload.enc === void 0 || payload.enc === null ? "plain" : String(payload.enc);
4599
4732
  if (tx.relayMode && tx.relayMode !== mode) throw new Error(`transfer ${fileId} mixed sealed and plaintext chunks`);
4600
4733
  if (!tx.relayMode) console.log(`[DAEMON] Relay transfer ${fileId}: ${mode}`);
4601
4734
  tx.relayMode = mode;
4602
4735
  const chunkBuffer = await openRelayChunk(payload);
4736
+ if (tx.status === "completed" || tx.assembling || tx.status === "cancelled") {
4737
+ ack();
4738
+ return;
4739
+ }
4603
4740
  tx.status = "downloading";
4604
4741
  tx.totalChunks = totalChunks;
4605
4742
  tx.peerId = from;
4606
- const chunkPath = path7.join(TEMP_DIR, `${fileId}_chunk_${chunkIndex}`);
4607
- const duplicate = fs8.existsSync(chunkPath);
4608
- if (!duplicate) {
4743
+ let d = relayDownloads.get(fileId);
4744
+ if (!d) {
4745
+ fs8.mkdirSync(TEMP_DIR, { recursive: true });
4746
+ const partPath = partPathOf(fileId);
4747
+ d = { fd: fs8.openSync(partPath, "w+"), partPath, got: /* @__PURE__ */ new Set(), totalChunks, lastStateAt: 0 };
4748
+ relayDownloads.set(fileId, d);
4749
+ }
4750
+ const isLast = chunkIndex === totalChunks - 1;
4751
+ const at = isLast && tx.size > 0 ? tx.size - chunkBuffer.length : chunkIndex * chunkBuffer.length;
4752
+ if (at < 0 || tx.size > 0 && at + chunkBuffer.length > tx.size) throw new Error(`relay chunk ${chunkIndex} of ${fileId} does not fit the file`);
4753
+ if (!d.got.has(chunkIndex)) {
4754
+ let written = 0;
4755
+ while (written < chunkBuffer.length) written += fs8.writeSync(d.fd, chunkBuffer, written, chunkBuffer.length - written, at + written);
4756
+ d.got.add(chunkIndex);
4609
4757
  tx.bytesTransferred += chunkBuffer.length;
4610
- tx.chunksCount++;
4758
+ tx.chunksCount = d.got.size;
4611
4759
  }
4612
- fs8.writeFileSync(chunkPath, chunkBuffer);
4613
- tx.progress = tx.bytesTransferred / tx.size * 100;
4760
+ tx.progress = tx.size > 0 ? Math.min(100, tx.bytesTransferred / tx.size * 100) : 100;
4614
4761
  const elapsed = (Date.now() - tx.startTime) / 1e3;
4615
4762
  if (elapsed > 0) {
4616
4763
  tx.speedKBps = tx.bytesTransferred / 1024 / elapsed;
4617
- tx.etaSeconds = (tx.size - tx.bytesTransferred) / (tx.speedKBps * 1024);
4764
+ tx.etaSeconds = tx.speedKBps > 0 ? (tx.size - tx.bytesTransferred) / (tx.speedKBps * 1024) : 0;
4618
4765
  }
4619
- writeStateFile();
4620
- broadcastSSE("transfer_progress", {
4621
- fileId,
4622
- fileName: tx.name,
4623
- size: tx.size,
4624
- bytesTransferred: tx.bytesTransferred,
4625
- progress: tx.progress,
4626
- speedBytesPerSecond: tx.speedKBps * 1024,
4627
- timeRemainingSeconds: tx.etaSeconds,
4628
- status: "downloading"
4629
- });
4630
- wsConn?.send(JSON.stringify({
4631
- type: "file_chunk_ack",
4632
- payload: { fileId, chunkIndex, status: "received" }
4633
- }));
4634
- if (!duplicate && totalChunks > 0 && tx.chunksCount >= totalChunks) {
4635
- setTimeout(() => {
4636
- if (tx.status === "downloading") handleFileComplete({ fileId });
4637
- }, 5e3).unref?.();
4766
+ const done = d.got.size >= totalChunks;
4767
+ const now = Date.now();
4768
+ if (done || now - d.lastStateAt >= 250) {
4769
+ d.lastStateAt = now;
4770
+ writeStateFile();
4771
+ broadcastSSE("transfer_progress", {
4772
+ fileId,
4773
+ fileName: tx.name,
4774
+ size: tx.size,
4775
+ bytesTransferred: tx.bytesTransferred,
4776
+ progress: tx.progress,
4777
+ speedBytesPerSecond: tx.speedKBps * 1024,
4778
+ timeRemainingSeconds: tx.etaSeconds,
4779
+ status: "downloading"
4780
+ });
4638
4781
  }
4782
+ ack();
4783
+ if (done) handleFileComplete({ fileId });
4639
4784
  }
4640
4785
  function handleFileComplete(payload) {
4641
4786
  const { fileId } = payload;
4642
4787
  const tx = activeTransfers.find((t) => t.fileId === fileId);
4643
- if (!tx || tx.assembling || tx.status === "completed") return;
4788
+ if (!tx || tx.direction !== "download" || tx.assembling || tx.status === "completed") return;
4789
+ const d = relayDownloads.get(fileId);
4790
+ if (tx.size > 0 && (!d || d.got.size < d.totalChunks)) return;
4644
4791
  tx.assembling = true;
4645
- const destDir = tx.saveDir || process.cwd();
4646
- const destPath = path7.join(destDir, tx.name);
4647
- try {
4648
- if (!fs8.existsSync(destDir)) {
4649
- fs8.mkdirSync(destDir, { recursive: true });
4650
- }
4651
- } catch (err) {
4652
- console.error(`[DAEMON] Failed to create destination directory ${destDir}:`, err);
4653
- tx.status = "error";
4654
- writeStateFile();
4655
- return;
4656
- }
4657
- const writeStream = fs8.createWriteStream(destPath);
4658
- writeStream.on("error", (err) => {
4659
- console.error(`[DAEMON] Write stream error for ${destPath}:`, err);
4792
+ const fail = (err) => {
4793
+ console.error(`[DAEMON] Could not save ${tx.name}:`, err?.message || err);
4660
4794
  tx.status = "error";
4661
4795
  writeStateFile();
4662
4796
  broadcastSSE("transfer_progress", {
@@ -4669,40 +4803,44 @@ function handleFileComplete(payload) {
4669
4803
  timeRemainingSeconds: 0,
4670
4804
  status: "error"
4671
4805
  });
4672
- });
4673
- writeStream.on("finish", () => {
4674
- console.log(`[DAEMON] File successfully downloaded and assembled: ${destPath}`);
4675
- tx.status = "completed";
4676
- tx.progress = 100;
4677
- tx.speedKBps = 0;
4678
- tx.etaSeconds = 0;
4679
- writeStateFile();
4680
- broadcastSSE("transfer_progress", {
4681
- fileId,
4682
- fileName: tx.name,
4683
- size: tx.size,
4684
- bytesTransferred: tx.size,
4685
- progress: 100,
4686
- speedBytesPerSecond: 0,
4687
- timeRemainingSeconds: 0,
4688
- status: "completed"
4689
- });
4690
- });
4691
- for (let i = 0; i < tx.totalChunks; i++) {
4692
- const chunkPath = path7.join(TEMP_DIR, `${fileId}_chunk_${i}`);
4693
- if (fs8.existsSync(chunkPath)) {
4806
+ };
4807
+ const destDir = tx.saveDir || process.cwd();
4808
+ const destPath = path7.join(destDir, tx.name);
4809
+ try {
4810
+ fs8.mkdirSync(destDir, { recursive: true });
4811
+ if (d) {
4812
+ fs8.fsyncSync(d.fd);
4813
+ dropRelayDownload(fileId, false);
4694
4814
  try {
4695
- const chunkBuf = fs8.readFileSync(chunkPath);
4696
- writeStream.write(chunkBuf);
4697
- fs8.unlinkSync(chunkPath);
4698
- } catch (err) {
4699
- console.error(`[DAEMON] Error reading/writing chunk ${i}:`, err);
4700
- writeStream.emit("error", err);
4701
- return;
4815
+ fs8.renameSync(d.partPath, destPath);
4816
+ } catch {
4817
+ fs8.copyFileSync(d.partPath, destPath);
4818
+ fs8.rmSync(d.partPath, { force: true });
4702
4819
  }
4820
+ } else {
4821
+ fs8.writeFileSync(destPath, Buffer.alloc(0));
4703
4822
  }
4823
+ } catch (err) {
4824
+ fail(err);
4825
+ return;
4704
4826
  }
4705
- writeStream.end();
4827
+ console.log(`[DAEMON] File successfully downloaded: ${destPath}`);
4828
+ tx.status = "completed";
4829
+ tx.progress = 100;
4830
+ tx.bytesTransferred = tx.size;
4831
+ tx.speedKBps = 0;
4832
+ tx.etaSeconds = 0;
4833
+ writeStateFile();
4834
+ broadcastSSE("transfer_progress", {
4835
+ fileId,
4836
+ fileName: tx.name,
4837
+ size: tx.size,
4838
+ bytesTransferred: tx.size,
4839
+ progress: 100,
4840
+ speedBytesPerSecond: 0,
4841
+ timeRemainingSeconds: 0,
4842
+ status: "completed"
4843
+ });
4706
4844
  }
4707
4845
  async function waitSignalReady(maxMs = 8e3) {
4708
4846
  if (!session.id) return { status: 409, body: { success: false, error: "No active session. Call /session/start first." } };
@@ -5179,7 +5317,7 @@ function embeddedDispatch(method, pathname, body) {
5179
5317
  }
5180
5318
  });
5181
5319
  }
5182
- var _WebTorrentCtor, _webTorrentLoadError, SRIFT_HOME, EMBEDDED, STATE_PATH, logPath, DAEMON_LOG_MAX, openLog, logBytes, DAEMON_LOG_MAX_AGE_MS, pruneOldLogs, logStream, logRotating, logPending, writeLogLine, logMessage, fmtLogArg, crypto6, PORT, DEFAULT_SIGNALER_URL, activeSignalerUrl, lastDiag, session, activeTransfers, chatHistory, pendingJoins, participants, sseClients, wsConn, signalingMode, wsFailures, httpModeSince, WS_RETRY_AFTER_MS, wsUrl, csrfToken, heartbeatInterval, lastHeartbeatAckAt, encryptionKey, sessionTerminated, terminationReason, activeTorrents, wtClient, pubshares, pubsharesByToken, activePulls, pubshareRegResolvers, TEMP_DIR, TMP_CHUNK_MAX_AGE_MS, TMP_FILE_MAX_AGE_MS, KDF_ITERATIONS, IV_LEN2, RELAY_ENC, relayKeyCache, keyring, relayReceivers, relaySenders, PUBLIC_TRACKERS, STATE_STALE_MS, activeUploads, RELAY_ACK_TIMEOUT_MS, RELAY_MAX_RETRIES, app, LOCAL_HOSTS, DAEMON_START_TIME, PACKAGE_VERSION, hostSessionInflight, MAX_MULTI_PATHS, MULTI_CONCURRENCY, embeddedReady, httpServer;
5320
+ var _WebTorrentCtor, _webTorrentLoadError, SRIFT_HOME, EMBEDDED, STATE_PATH, logPath, DAEMON_LOG_MAX, openLog, logBytes, DAEMON_LOG_MAX_AGE_MS, pruneOldLogs, logStream, logRotating, logPending, writeLogLine, logMessage, fmtLogArg, crypto6, PORT, DEFAULT_SIGNALER_URL, activeSignalerUrl, lastDiag, session, activeTransfers, chatHistory, pendingJoins, participants, sseClients, wsConn, signalingMode, wsFailures, httpModeSince, WS_RETRY_AFTER_MS, wsUrl, csrfToken, heartbeatInterval, lastHeartbeatAckAt, encryptionKey, sessionTerminated, terminationReason, activeTorrents, wtClient, pubshares, pubsharesByToken, activePulls, pubshareRegResolvers, TEMP_DIR, TMP_CHUNK_MAX_AGE_MS, TMP_FILE_MAX_AGE_MS, KDF_ITERATIONS, IV_LEN2, RELAY_ENC, relayKeyCache, keyring, relayReceivers, relaySenders, PUBLIC_TRACKERS, STATE_STALE_MS, activeUploads, uploadKey, uploadsOf, RELAY_ACK_TIMEOUT_MS, RELAY_STALL_MS, RELAY_WINDOW, RELAY_LEGACY_CHUNK, RELAY_FAST_CHUNK, RELAY_FORMAT, RELAY_SOCKET_HIGH_BYTES, relayDownloads, partPathOf, app, LOCAL_HOSTS, DAEMON_START_TIME, PACKAGE_VERSION, hostSessionInflight, MAX_MULTI_PATHS, MULTI_CONCURRENCY, embeddedReady, httpServer;
5183
5321
  var init_daemon = __esm({
5184
5322
  "../../cli/daemon.ts"() {
5185
5323
  "use strict";
@@ -5361,8 +5499,17 @@ var init_daemon = __esm({
5361
5499
  STATE_STALE_MS = 24 * 60 * 60 * 1e3;
5362
5500
  pruneStaleStateOnStartup();
5363
5501
  activeUploads = /* @__PURE__ */ new Map();
5502
+ uploadKey = (fileId, userId) => `${fileId}|${userId}`;
5503
+ uploadsOf = (fileId) => [...activeUploads.values()].filter((u) => u.fileId === fileId);
5364
5504
  RELAY_ACK_TIMEOUT_MS = Number(process.env.SRIFT_RELAY_ACK_TIMEOUT_MS) || 15e3;
5365
- RELAY_MAX_RETRIES = 40;
5505
+ RELAY_STALL_MS = RELAY_ACK_TIMEOUT_MS * 40;
5506
+ RELAY_WINDOW = 16;
5507
+ RELAY_LEGACY_CHUNK = 64 * 1024;
5508
+ RELAY_FAST_CHUNK = 256 * 1024;
5509
+ RELAY_FORMAT = "b64";
5510
+ RELAY_SOCKET_HIGH_BYTES = 8 * 1024 * 1024;
5511
+ relayDownloads = /* @__PURE__ */ new Map();
5512
+ partPathOf = (fileId) => path7.join(TEMP_DIR, `${fileId.replace(/[^A-Za-z0-9_.-]/g, "_")}_chunk_part`);
5366
5513
  app = express();
5367
5514
  LOCAL_HOSTS = /* @__PURE__ */ new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`, `[::1]:${PORT}`]);
5368
5515
  app.use((req, res, next) => {
@@ -5378,7 +5525,7 @@ var init_daemon = __esm({
5378
5525
  app.use(cors({ origin: "*", maxAge: 600 }));
5379
5526
  app.use(express.json());
5380
5527
  DAEMON_START_TIME = Date.now();
5381
- PACKAGE_VERSION = "4.3.0";
5528
+ PACKAGE_VERSION = "4.3.2";
5382
5529
  app.get("/health", (req, res) => {
5383
5530
  res.json({
5384
5531
  ok: true,
@@ -5669,7 +5816,7 @@ var init_daemon = __esm({
5669
5816
  }
5670
5817
  wsConn?.send(JSON.stringify({
5671
5818
  type: "file_accept",
5672
- payload: { fileId, relayEnc: RELAY_ENC, ...relayPk ? { relayPk } : {} }
5819
+ payload: { fileId, relayEnc: RELAY_ENC, relayFmt: RELAY_FORMAT, ...relayPk ? { relayPk } : {} }
5673
5820
  }));
5674
5821
  res.json({ success: true });
5675
5822
  });
@@ -6030,7 +6177,7 @@ data: /mcp/messages
6030
6177
  wtClient = null;
6031
6178
  }
6032
6179
  activeTorrents.clear();
6033
- activeUploads.clear();
6180
+ for (const u of [...activeUploads.values()]) stopUpload(u);
6034
6181
  session = { id: null, name: null, role: null, isConnected: false, userId: null };
6035
6182
  activeTransfers = [];
6036
6183
  chatHistory = [];
@@ -6093,7 +6240,7 @@ data: /mcp/messages
6093
6240
  }
6094
6241
  try {
6095
6242
  activeTorrents.clear();
6096
- activeUploads.clear();
6243
+ for (const u of [...activeUploads.values()]) stopUpload(u);
6097
6244
  } catch {
6098
6245
  }
6099
6246
  try {
@@ -13451,7 +13598,7 @@ async function startAgentNetMcp(opts = {}) {
13451
13598
  process.on("SIGINT", shutdown);
13452
13599
  process.on("SIGTERM", shutdown);
13453
13600
  }
13454
- var SERVER_INFO2, LEGACY_VERSIONS, INBOX_URI, DISCOVERIES_URI, LOG_URI, to, obj, AGENTNET_TOOLS, discoveries, RESOURCES;
13601
+ var SERVER_INFO2, LEGACY_VERSIONS, INBOX_URI, DISCOVERIES_URI, LOG_URI, to, obj, AGENTNET_TOOLS, TOOL_META, discoveries, RESOURCES;
13455
13602
  var init_mcp2 = __esm({
13456
13603
  "../../cli/agentnet/mcp.ts"() {
13457
13604
  "use strict";
@@ -13595,6 +13742,39 @@ var init_mcp2 = __esm({
13595
13742
  inputSchema: obj({ knockPolicy: { type: "string", enum: ["ask", "accept", "reject"] }, off: { type: "boolean" } })
13596
13743
  }
13597
13744
  ];
13745
+ TOOL_META = {
13746
+ srift_an_whoami: ["My AgentNet Address", true, false, false, true],
13747
+ srift_an_announce: ["Announce Agent", false, false, true, true],
13748
+ srift_an_search: ["Search Online Agents", true, false, true, true],
13749
+ srift_an_find: ["Find Agent", true, false, true, true],
13750
+ srift_an_knock: ["Knock on Agent", false, false, true, false],
13751
+ srift_an_answer_knock: ["Answer Knock", false, false, true, false],
13752
+ srift_an_connect: ["Find and Connect to Agent", false, false, true, false],
13753
+ srift_an_status: ["Agent Online Status", true, false, true, true],
13754
+ srift_an_send_message: ["Send Message to Agent", false, false, true, false],
13755
+ srift_an_send_file: ["Send File to Agent", false, false, true, false],
13756
+ srift_an_files: ["Received Files", true, false, false, true],
13757
+ srift_an_history: ["Conversation History", true, false, false, true],
13758
+ srift_an_group_create: ["Create Group", false, false, true, false],
13759
+ srift_an_group_manage: ["Manage Group", false, true, true, false],
13760
+ srift_an_group_send: ["Send Group Message", false, false, true, false],
13761
+ srift_an_group_list: ["List Groups", true, false, false, true],
13762
+ srift_an_group_call: ["Start Group Call", false, false, true, false],
13763
+ srift_an_inbox: ["Read Inbox", false, false, true, false],
13764
+ srift_an_call: ["Call Agent", false, false, true, false],
13765
+ srift_an_accept_call: ["Accept Call", false, false, true, false],
13766
+ srift_an_calls: ["Manage Calls", false, false, true, false],
13767
+ srift_an_invite: ["Create Invite Link", false, false, false, false],
13768
+ srift_an_contacts_add: ["Add Contact", false, false, false, true],
13769
+ srift_an_block: ["Block Agent", false, true, false, true],
13770
+ srift_an_set_hook: ["Set Knock Policy", false, false, false, true]
13771
+ };
13772
+ for (const tool of AGENTNET_TOOLS) {
13773
+ const m = TOOL_META[tool.name];
13774
+ if (!m) continue;
13775
+ tool.title = m[0];
13776
+ tool.annotations = { title: m[0], readOnlyHint: m[1], destructiveHint: m[2], openWorldHint: m[3], idempotentHint: m[4] };
13777
+ }
13598
13778
  discoveries = [];
13599
13779
  RESOURCES = [
13600
13780
  { uri: INBOX_URI, name: "AgentNet inbox", description: "Unread messages, knocks and call invites (subscribe for notifications)", mimeType: "application/json" },
@@ -15691,7 +15871,7 @@ var __filename = fileURLToPath2(import.meta.url);
15691
15871
  var __dirname = path14.dirname(__filename);
15692
15872
  var DAEMON_PORT3 = parseInt(process.env.SRIFT_DAEMON_PORT || "3822", 10);
15693
15873
  var DAEMON_URL3 = `http://127.0.0.1:${DAEMON_PORT3}`;
15694
- var CLI_VERSION = "4.3.0";
15874
+ var CLI_VERSION = "4.3.2";
15695
15875
  function parseDuration(s) {
15696
15876
  if (!s) return 0;
15697
15877
  const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i.exec(s.trim());