@vdoninja/ninja-p2p 0.1.4 → 0.2.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.
Files changed (46) hide show
  1. package/.agents/skills/ninja-p2p/SKILL.md +102 -2
  2. package/.claude/skills/ninja-p2p/SKILL.md +209 -130
  3. package/.codex/skills/ninja-p2p/SKILL.md +102 -2
  4. package/CHANGELOG.md +113 -0
  5. package/README.md +1077 -775
  6. package/dashboard.html +370 -50
  7. package/dist/agent-state.js +9 -0
  8. package/dist/cli-lib.d.ts +40 -0
  9. package/dist/cli-lib.js +165 -2
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +501 -10
  12. package/dist/demo.d.ts +37 -0
  13. package/dist/demo.js +186 -0
  14. package/dist/doctor.d.ts +52 -0
  15. package/dist/doctor.js +219 -0
  16. package/dist/file-transfer.d.ts +15 -2
  17. package/dist/file-transfer.js +249 -15
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/message-bus.d.ts +15 -0
  21. package/dist/message-bus.js +32 -3
  22. package/dist/peer-registry.js +7 -0
  23. package/dist/protocol.d.ts +78 -1
  24. package/dist/protocol.js +42 -6
  25. package/dist/shared-folders.js +20 -1
  26. package/dist/social-stream.d.ts +100 -0
  27. package/dist/social-stream.js +254 -0
  28. package/dist/swarm-manager.d.ts +164 -0
  29. package/dist/swarm-manager.js +957 -0
  30. package/dist/swarm-session.d.ts +197 -0
  31. package/dist/swarm-session.js +465 -0
  32. package/dist/swarm-wire.d.ts +48 -0
  33. package/dist/swarm-wire.js +86 -0
  34. package/dist/swarm.d.ts +258 -0
  35. package/dist/swarm.js +694 -0
  36. package/dist/vdo-bridge.d.ts +62 -1
  37. package/dist/vdo-bridge.js +273 -23
  38. package/dist/vdoninja-sdk-types.d.ts +75 -0
  39. package/dist/vdoninja-sdk-types.js +10 -0
  40. package/dist/wake.d.ts +83 -0
  41. package/dist/wake.js +206 -0
  42. package/docs/protocol-and-reliability.md +231 -38
  43. package/docs/sdk-wishlist.md +236 -0
  44. package/docs/security.md +197 -0
  45. package/docs/social-stream-bridge.md +390 -0
  46. package/package.json +125 -116
package/dist/wake.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Wake Hooks
3
+ *
4
+ * Turn-based agents (Claude Code, Codex CLI) only act when something hands them
5
+ * a turn. The sidecar will happily hold a message forever while the agent sits
6
+ * idle and never notices. A wake hook closes that gap: when real messages land
7
+ * in the inbox, run a shell command so the agent gets a turn.
8
+ *
9
+ * Safety matters more than latency here. Two agents that both wake on message
10
+ * and both reply will ping-pong forever, burning tokens with nobody watching.
11
+ * So runs are coalesced into batches, never overlapped, and rate limited.
12
+ */
13
+ import type { MessageEnvelope } from "./protocol.js";
14
+ export declare const DEFAULT_WAKE_DEBOUNCE_MS = 750;
15
+ export declare const DEFAULT_WAKE_LIMIT_PER_MINUTE = 30;
16
+ /** Keep peer-controlled preview text well below Windows' process environment limit. */
17
+ export declare const MAX_WAKE_TEXT_LENGTH = 4096;
18
+ export declare const MAX_WAKE_CONTEXT_LENGTH = 4096;
19
+ export type WakeConfig = {
20
+ command: string;
21
+ debounceMs: number;
22
+ limitPerMinute: number;
23
+ };
24
+ /** Minimal shape of a spawned process, so tests can inject a fake. */
25
+ export type WakeChild = {
26
+ on(event: "exit", listener: () => void): unknown;
27
+ on(event: "error", listener: (error: Error) => void): unknown;
28
+ };
29
+ export type WakeSpawn = (command: string, env: NodeJS.ProcessEnv) => WakeChild;
30
+ export type WakeContext = {
31
+ streamId: string;
32
+ room: string;
33
+ stateDir: string;
34
+ };
35
+ export type WakeRunnerOptions = {
36
+ config: WakeConfig;
37
+ context: WakeContext;
38
+ spawnFn?: WakeSpawn;
39
+ now?: () => number;
40
+ log?: (message: string) => void;
41
+ };
42
+ /**
43
+ * Environment handed to the wake command.
44
+ *
45
+ * NINJA_ID and NINJA_STATE_DIR are set on purpose: they let the woken command
46
+ * run bare `ninja-p2p read` / `ninja-p2p dm <peer> <text>` and have those route
47
+ * through this sidecar's local state.
48
+ *
49
+ * NINJA_ROOM is deliberately NOT set. The CLI treats a room as "one-shot mode"
50
+ * and would open a second WebRTC connection under the same streamId instead of
51
+ * queueing through the running sidecar. The room is exposed as NINJA_WAKE_ROOM
52
+ * for display purposes only.
53
+ */
54
+ export declare function buildWakeEnv(batch: MessageEnvelope[], context: WakeContext): Record<string, string>;
55
+ export declare class WakeRunner {
56
+ private readonly config;
57
+ private readonly context;
58
+ private readonly spawnFn;
59
+ private readonly now;
60
+ private readonly log;
61
+ private pending;
62
+ private timer;
63
+ private running;
64
+ private rerunRequested;
65
+ private recentRuns;
66
+ private disposed;
67
+ constructor(options: WakeRunnerOptions);
68
+ /** Record an inbox message and schedule a wake. */
69
+ notify(envelope: MessageEnvelope): void;
70
+ dispose(): void;
71
+ /** Test seam: whether a wake command is currently executing. */
72
+ isRunning(): boolean;
73
+ /** Test seam: how many messages are waiting for the next wake. */
74
+ pendingCount(): number;
75
+ private schedule;
76
+ private clearTimer;
77
+ private fire;
78
+ /** Milliseconds to wait before another run is allowed, or 0 if one is. */
79
+ private throttleDelayMs;
80
+ private run;
81
+ /** After a run finishes, start another if messages arrived meanwhile. */
82
+ private settle;
83
+ }
package/dist/wake.js ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Wake Hooks
3
+ *
4
+ * Turn-based agents (Claude Code, Codex CLI) only act when something hands them
5
+ * a turn. The sidecar will happily hold a message forever while the agent sits
6
+ * idle and never notices. A wake hook closes that gap: when real messages land
7
+ * in the inbox, run a shell command so the agent gets a turn.
8
+ *
9
+ * Safety matters more than latency here. Two agents that both wake on message
10
+ * and both reply will ping-pong forever, burning tokens with nobody watching.
11
+ * So runs are coalesced into batches, never overlapped, and rate limited.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ export const DEFAULT_WAKE_DEBOUNCE_MS = 750;
15
+ export const DEFAULT_WAKE_LIMIT_PER_MINUTE = 30;
16
+ /** Keep peer-controlled preview text well below Windows' process environment limit. */
17
+ export const MAX_WAKE_TEXT_LENGTH = 4_096;
18
+ export const MAX_WAKE_CONTEXT_LENGTH = 4_096;
19
+ /**
20
+ * Environment handed to the wake command.
21
+ *
22
+ * NINJA_ID and NINJA_STATE_DIR are set on purpose: they let the woken command
23
+ * run bare `ninja-p2p read` / `ninja-p2p dm <peer> <text>` and have those route
24
+ * through this sidecar's local state.
25
+ *
26
+ * NINJA_ROOM is deliberately NOT set. The CLI treats a room as "one-shot mode"
27
+ * and would open a second WebRTC connection under the same streamId instead of
28
+ * queueing through the running sidecar. The room is exposed as NINJA_WAKE_ROOM
29
+ * for display purposes only.
30
+ */
31
+ export function buildWakeEnv(batch, context) {
32
+ return {
33
+ NINJA_ID: context.streamId,
34
+ NINJA_STATE_DIR: context.stateDir,
35
+ NINJA_WAKE_ROOM: context.room,
36
+ NINJA_WAKE_COUNT: String(batch.length),
37
+ NINJA_WAKE_FROM: joinBounded(unique(batch.map((envelope) => envelope.from.streamId))),
38
+ NINJA_WAKE_TYPES: joinBounded(unique(batch.map((envelope) => envelope.type))),
39
+ NINJA_WAKE_TEXT: firstText(batch),
40
+ };
41
+ }
42
+ export class WakeRunner {
43
+ config;
44
+ context;
45
+ spawnFn;
46
+ now;
47
+ log;
48
+ pending = [];
49
+ timer = null;
50
+ running = false;
51
+ rerunRequested = false;
52
+ recentRuns = [];
53
+ disposed = false;
54
+ constructor(options) {
55
+ this.config = options.config;
56
+ this.context = options.context;
57
+ this.spawnFn = options.spawnFn ?? defaultWakeSpawn;
58
+ this.now = options.now ?? (() => Date.now());
59
+ this.log = options.log ?? (() => { });
60
+ }
61
+ /** Record an inbox message and schedule a wake. */
62
+ notify(envelope) {
63
+ if (this.disposed)
64
+ return;
65
+ this.pending.push(envelope);
66
+ this.schedule(this.config.debounceMs);
67
+ }
68
+ dispose() {
69
+ this.disposed = true;
70
+ this.clearTimer();
71
+ this.pending = [];
72
+ }
73
+ /** Test seam: whether a wake command is currently executing. */
74
+ isRunning() {
75
+ return this.running;
76
+ }
77
+ /** Test seam: how many messages are waiting for the next wake. */
78
+ pendingCount() {
79
+ return this.pending.length;
80
+ }
81
+ schedule(delayMs) {
82
+ if (this.disposed || this.timer)
83
+ return;
84
+ // Fixed window rather than a resetting debounce. A resetting timer can be
85
+ // starved forever by a steady message stream; this bounds wake latency to
86
+ // roughly debounceMs no matter how chatty the room gets.
87
+ this.timer = setTimeout(() => {
88
+ this.timer = null;
89
+ this.fire();
90
+ }, delayMs);
91
+ if (typeof this.timer.unref === "function")
92
+ this.timer.unref();
93
+ }
94
+ clearTimer() {
95
+ if (this.timer) {
96
+ clearTimeout(this.timer);
97
+ this.timer = null;
98
+ }
99
+ }
100
+ fire() {
101
+ if (this.disposed || this.pending.length === 0)
102
+ return;
103
+ if (this.running) {
104
+ // Do not run two wake commands at once; an agent turn is not reentrant.
105
+ this.rerunRequested = true;
106
+ this.log(`[wake] busy, ${this.pending.length} message(s) will wait for the current run`);
107
+ return;
108
+ }
109
+ const waitMs = this.throttleDelayMs();
110
+ if (waitMs > 0) {
111
+ this.log(`[wake] rate limit of ${this.config.limitPerMinute}/min reached, deferring ${waitMs}ms`);
112
+ this.schedule(waitMs);
113
+ return;
114
+ }
115
+ this.run();
116
+ }
117
+ /** Milliseconds to wait before another run is allowed, or 0 if one is. */
118
+ throttleDelayMs() {
119
+ const limit = this.config.limitPerMinute;
120
+ if (limit <= 0)
121
+ return 0;
122
+ const now = this.now();
123
+ this.recentRuns = this.recentRuns.filter((at) => now - at < 60_000);
124
+ if (this.recentRuns.length < limit)
125
+ return 0;
126
+ return 60_000 - (now - this.recentRuns[0]) + 1;
127
+ }
128
+ run() {
129
+ const batch = this.pending;
130
+ this.pending = [];
131
+ this.running = true;
132
+ this.recentRuns.push(this.now());
133
+ const env = { ...process.env, ...buildWakeEnv(batch, this.context) };
134
+ this.log(`[wake] ${batch.length} message(s) from ${env.NINJA_WAKE_FROM || "unknown"} -> ${this.config.command}`);
135
+ let child;
136
+ try {
137
+ child = this.spawnFn(this.config.command, env);
138
+ }
139
+ catch (error) {
140
+ this.running = false;
141
+ this.log(`[wake] failed to start: ${error instanceof Error ? error.message : String(error)}`);
142
+ this.settle();
143
+ return;
144
+ }
145
+ let settled = false;
146
+ const finish = (error) => {
147
+ if (settled)
148
+ return;
149
+ settled = true;
150
+ this.running = false;
151
+ if (error)
152
+ this.log(`[wake] process error: ${error.message}`);
153
+ this.settle();
154
+ };
155
+ // A failed asynchronous spawn emits `error`; without a listener Node treats
156
+ // it as uncaught, and without settling the runner stays busy forever.
157
+ child.on("error", (error) => finish(error));
158
+ child.on("exit", () => finish());
159
+ }
160
+ /** After a run finishes, start another if messages arrived meanwhile. */
161
+ settle() {
162
+ if (this.disposed)
163
+ return;
164
+ if (this.rerunRequested || this.pending.length > 0) {
165
+ this.rerunRequested = false;
166
+ this.schedule(this.config.debounceMs);
167
+ }
168
+ }
169
+ }
170
+ function defaultWakeSpawn(command, env) {
171
+ // Inherit stdio so the wake command's output lands in the sidecar log,
172
+ // which is the only place a detached agent can report for itself.
173
+ return spawn(command, {
174
+ shell: true,
175
+ env,
176
+ stdio: ["ignore", "inherit", "inherit"],
177
+ windowsHide: true,
178
+ });
179
+ }
180
+ function unique(values) {
181
+ return [...new Set(values.filter(Boolean))];
182
+ }
183
+ function joinBounded(values) {
184
+ let joined = "";
185
+ for (const value of values) {
186
+ const next = joined ? `${joined},${value}` : value;
187
+ if (next.length > MAX_WAKE_CONTEXT_LENGTH)
188
+ break;
189
+ joined = next;
190
+ }
191
+ return joined;
192
+ }
193
+ function firstText(batch) {
194
+ for (const envelope of batch) {
195
+ const payload = envelope.payload;
196
+ if (typeof payload !== "object" || payload === null)
197
+ continue;
198
+ const text = payload.text;
199
+ if (typeof text === "string" && text.trim()) {
200
+ return text.length <= MAX_WAKE_TEXT_LENGTH
201
+ ? text
202
+ : `${text.slice(0, MAX_WAKE_TEXT_LENGTH - 1)}…`;
203
+ }
204
+ }
205
+ return "";
206
+ }
@@ -1,38 +1,231 @@
1
- # Protocol and reliability
2
-
3
- `ninja-p2p` is an agent coordination layer over VDO.Ninja WebRTC data channels. It does not replace or extend VDO.Ninja signaling.
4
-
5
- ## Layers
6
-
7
- 1. The VDO.Ninja SDK joins a room, discovers published stream IDs, and establishes WebRTC peer connections and data channels.
8
- 2. `VDOBridge` asks the SDK to view discovered peers as data-only connections.
9
- 3. `MessageBus` sends a versioned JSON envelope through those channels.
10
- 4. The optional sidecar stores inbox and outbox records on the local machine so a turn-based agent can read them later.
11
-
12
- The application envelope contains an ID, timestamp, sender identity, message type, optional target/topic, and payload. It is data carried through VDO.Ninja, not a new WebSocket command.
13
-
14
- ## Delivery semantics
15
-
16
- - Direct messages are sent when the SDK accepts them for an open data channel.
17
- - If a known peer is offline, or the channel exists but is not ready yet, the message is queued in memory.
18
- - A queue flush retries the original envelope, including its message ID and type, and removes only messages the SDK accepted. A failed flush leaves the remaining messages in order for the next channel-open or announce event.
19
- - The persistent CLI sidecar additionally stores actions on disk before its live process sends them.
20
- - There is no durable remote broker and no exactly-once guarantee. Applications that require confirmation should use message IDs with `ack` or `command_response` and make handlers idempotent.
21
- - Room broadcasts are best-effort. They are not retained independently for every absent peer.
22
-
23
- ## Connection race this prevents
24
-
25
- WebRTC emits several milestones. `peerConnected` can occur before `dataChannelOpen`. Older code treated the first event as send-ready, so an agent replying in that interval could receive a normal-looking local envelope even though the SDK rejected the send. The bridge now observes the SDK's boolean send result and queues an explicitly rejected direct message.
26
-
27
- A second race occurred during reconnect: the queue was deleted before replay attempts. If the data channel closed between the open event and the first replay, every queued message was lost locally. Queue entries now remain until each replay is accepted.
28
-
29
- ## Compatibility
30
-
31
- - The wire envelope format is unchanged.
32
- - Existing send callbacks that return `void` remain accepted; only an explicit `false` means the transport rejected a send.
33
- - SDK targets use the SDK's documented `{ uuid }` or `{ streamID }` forms.
34
- - Existing VDO.Ninja room and signaling behavior remains authoritative.
35
-
36
- ## Security boundary
37
-
38
- WebRTC encrypts data in transit. Room names and optional VDO.Ninja passwords control discovery/connection behavior, but they are not an application authorization system. Validate commands at the receiving agent, expose only intentional shared folders, and do not treat peer-supplied names or capabilities as trusted identity claims.
1
+ # Protocol and reliability
2
+
3
+ `ninja-p2p` is an agent coordination layer over VDO.Ninja WebRTC data channels. It does not replace or extend VDO.Ninja signaling.
4
+
5
+ ## Layers
6
+
7
+ 1. The VDO.Ninja SDK joins a room, discovers published stream IDs, and establishes WebRTC peer connections and data channels.
8
+ 2. `VDOBridge` asks the SDK to view discovered peers as data-only connections.
9
+ 3. `MessageBus` sends a versioned JSON envelope through those channels.
10
+ 4. The optional sidecar stores inbox and outbox records on the local machine so a turn-based agent can read them later.
11
+
12
+ The application envelope contains an ID, timestamp, sender identity, message type, optional target/topic, and payload. It is data carried through VDO.Ninja, not a new WebSocket command.
13
+
14
+ ## Delivery semantics
15
+
16
+ - Direct messages are sent when the SDK accepts them for an open data channel.
17
+ - If a known peer is offline, or the channel exists but is not ready yet, the message is queued in memory.
18
+ - A queue flush retries the original envelope, including its message ID and type, and removes only messages the SDK accepted. A failed flush leaves the remaining messages in order for the next channel-open or announce event.
19
+ - The persistent CLI sidecar additionally stores actions on disk before its live process sends them.
20
+ - There is no durable remote broker and no exactly-once guarantee. Applications that require confirmation should use message IDs with `ack` or `command_response` and make handlers idempotent.
21
+ - Room broadcasts are best-effort. They are not retained independently for every absent peer.
22
+ - A history request is capped at 200 entries (invalid or non-positive counts
23
+ default to 50) and can replay broadcasts plus messages sent to or by the
24
+ requester. Direct messages involving other peers are filtered before replay.
25
+
26
+ ## Connection race this prevents
27
+
28
+ WebRTC emits several milestones. `peerConnected` can occur before `dataChannelOpen`. Older code treated the first event as send-ready, so an agent replying in that interval could receive a normal-looking local envelope even though the SDK rejected the send. The bridge now observes the SDK's boolean send result and queues an explicitly rejected direct message.
29
+
30
+ A second race occurred during reconnect: the queue was deleted before replay attempts. If the data channel closed between the open event and the first replay, every queued message was lost locally. Queue entries now remain until each replay is accepted.
31
+
32
+ ## Swarm transfer
33
+
34
+ Bulk file transfer runs its own protocol on top of the same envelope, with one
35
+ exception: chunk payloads travel as raw bytes on a dedicated binary channel
36
+ rather than inside an envelope, when both peers can.
37
+
38
+ ### Two lanes, deliberately
39
+
40
+ Control messages — offers, bitfields, requests, progress — stay on the shared
41
+ control channel. Chunk bytes go on a separate reserved `x-bin` channel.
42
+
43
+ That split is the single largest thing separating this from the original
44
+ transfer path, and not for the reason that looks obvious. Base64 costs 33% in
45
+ bandwidth, which is real but modest. The larger cost was that a 64 KB chunk on
46
+ the control channel sat in front of every chunk request queued behind it. With
47
+ two downloaders sharing what they had, the control channel became the
48
+ bottleneck: measured at 627 KB/s each without the binary lane against 7.1 MB/s
49
+ with it.
50
+
51
+ Binary requires `@vdoninja/sdk` 1.4.1 or newer. Older peers are not excluded —
52
+ each request states whether its sender can receive bytes, so the answer is
53
+ chosen per request and a room can mix versions freely.
54
+
55
+ ### Why the manifest is paged
56
+
57
+ The offer originally carried one 64-character hash per chunk. At 250 MB with
58
+ the default 64 KB chunks, that produces an offer around 275 KB — already larger
59
+ than a common 256 KB SCTP message limit. The transfer was valid but impossible
60
+ to announce.
61
+
62
+ Offers now carry the file shape plus a digest of the ordered chunk-hash list.
63
+ Small lists remain inline. Large lists are requested in pages of at most 256
64
+ hashes, and the assembled list is checked against its digest before a part file
65
+ is opened. This bounds every control message and also keeps an unsolicited
66
+ large offer from allocating a full manifest until somebody asks for the file.
67
+
68
+ ### Why ties are broken at random
69
+
70
+ Rarest-chunk-first is the standard rule, but at the start of a download every
71
+ chunk is equally rare. Breaking those ties by index meant every downloader
72
+ requested the same chunks in the same order, so no downloader ever held anything
73
+ another lacked, and the "a partial downloader is already a source" property was
74
+ true in code and worthless in practice. Three downloaders against one seeder
75
+ measured at exactly one third the speed of one, with no peer-to-peer traffic at
76
+ all.
77
+
78
+ Random tie-breaking makes downloaders diverge from the first request. Total
79
+ throughput across three downloaders went from 7.8 MB/s to 14.1 MB/s.
80
+
81
+ ### Startup
82
+
83
+ A downloader that has just learned a file exists knows nothing about who holds
84
+ it. Waiting for the next periodic bitfield broadcast cost a fixed 2.6 s of a
85
+ 4.0 s transfer. A peer now answers another peer's bitfield with its own,
86
+ unicast, but only when it holds something that peer lacks — which both makes the
87
+ reply useful and guarantees the exchange terminates after one round.
88
+
89
+ ### What was measured and deliberately not built
90
+
91
+ - **Adaptive in-flight windows.** Throughput was identical at 4, 16 and 48
92
+ outstanding requests per peer, so the window is not the constraint and an
93
+ adaptive one would be complexity without a benefit. The SDK's own drain wait
94
+ already bounds the send buffer, measured at 1.08 MB against a 1 MB
95
+ high-water mark.
96
+ - **Larger chunks.** 64 KB, 128 KB and 192 KB were within 2% of each other. The
97
+ negotiated SCTP limit is used as a safety check instead: at the 64 KB default
98
+ a base64 chunk is 85 KB, which would be refused by a peer negotiating the
99
+ 65536 every implementation must support.
100
+
101
+ ### Losing a chunk on a healthy link
102
+
103
+ Chunks are occasionally dropped with nothing wrong anywhere: measured on an idle
104
+ machine with three downloaders, one lost a chunk in roughly one run in three.
105
+ The only thing that notices is the request timeout, so its length is the whole
106
+ cost of a loss — a flat 15s turned a 2s transfer into 17s, which is where the
107
+ worst outliers in the table above come from.
108
+
109
+ The timeout is therefore scaled to the peer's measured round-trip time, with a
110
+ 3s floor and the 15s ceiling kept for any peer we have not timed yet. The floor
111
+ is the important half: a peer serving several downloaders is slow rather than
112
+ broken, and expiring it early costs twice, because the chunk is re-requested
113
+ *and* the peer is charged a failure it did not earn.
114
+
115
+ This was built, reverted, and reinstated. The revert was based on a measured
116
+ 2.5x throughput regression that turned out to be an artefact — over a hundred
117
+ stray test daemons from earlier runs were still sitting in rooms on the same
118
+ machine, because killing a shell job on Windows does not reach the node process
119
+ it spawned. Re-measured on a genuinely idle machine, the regression did not
120
+ exist. Any benchmark here is worth nothing without checking what else is
121
+ running first.
122
+
123
+ ### Resume
124
+
125
+ Keeping a part file's bytes across a restart is only half of resuming. The
126
+ bitfield was rebuilt empty, so every chunk already on disk was fetched again —
127
+ "resumable" was true of the file and false of the transfer, and no test caught it
128
+ because none ever constructed a second session over an existing part file.
129
+
130
+ A download now hashes its part file on startup and credits the chunks that
131
+ verify. Verification is per chunk rather than inferred from the file's length:
132
+ chunks are written at byte offsets, so a gap reads back as zeros and an
133
+ interrupted write leaves a partial chunk, and neither is distinguishable from
134
+ real data by size. The cost is one pass over the file, paid only when a part
135
+ file already exists.
136
+
137
+ A part file that turns out to be complete — a run interrupted between the last
138
+ chunk and the rename — is finished immediately, since nothing else would ever
139
+ drive it to completion.
140
+
141
+ ### Surviving a network drop
142
+
143
+ Closing a downloader's signalling socket mid-transfer produced the most
144
+ surprising result of any test here: chunk requests still arrived at the seeder,
145
+ the seeder's `send()` returned success, the data channel reported `open` at both
146
+ ends — and the bytes never landed. Measured directly: the seeder handed 331
147
+ chunks to the transport with zero failures and a worst case of 81 ms, and the
148
+ downloader received 327.
149
+
150
+ **No layer below the application notices a path like that.** Everything reports
151
+ health. The only evidence is a peer that stops delivering.
152
+
153
+ What the transfer does about it:
154
+
155
+ - Swarm messages are transient (above), so a request lost to the outage is not
156
+ replayed later asking for a chunk we now hold.
157
+ - A request is only counted as outstanding if the transport accepted it.
158
+ Assuming otherwise meant a chunk was held hostage for a full timeout by a
159
+ request that never left.
160
+ - On reconnect, everything outstanding is abandoned and re-planned rather than
161
+ timed out one chunk at a time, and no peer is charged a failure — the outage
162
+ was ours.
163
+ - `viewedStreamIds` is cleared on reconnect. It was only ever cleared on
164
+ shutdown, so after a blip every peer looked "already viewed" and nothing we
165
+ did could re-establish one.
166
+ - A peer that misses several requests in a row **with nothing delivered in
167
+ between** has its connection torn down and rebuilt. Measured with healthy
168
+ signalling, a rebuild restores a working path in ~260 ms.
169
+
170
+ Rebuilding is deliberately held off while the SDK is itself reconnecting, plus a
171
+ short grace afterwards. It replays its own view intent then, and rebuilding
172
+ underneath that races it — ungated, three seeders sometimes never recovered at
173
+ all; gated, every configuration tested recovers.
174
+
175
+ **The honest limit:** a transfer always recovers, but not quickly. A 60 MB
176
+ transfer interrupted at 30% completed in about 55 s against roughly 5 s
177
+ uninterrupted, and more peers makes it slower rather than faster, since each one
178
+ has its own dead path to notice. The remaining cost is in re-establishing peer
179
+ connections after a signalling reconnect, which is below this layer.
180
+
181
+ ### Concurrent downloads of the same file
182
+
183
+ In-progress files are keyed by content **and** destination. Keying by content
184
+ alone meant two `fetch` runs of the same file on one machine wrote into a single
185
+ part file and the first to finish renamed it out from under the others. A part
186
+ file is also locked while in use, so the one genuinely ambiguous case — two
187
+ downloads of the same file into the same folder — fails with a clear message
188
+ instead of interleaving writes. Locks left by a killed process go stale after
189
+ five minutes.
190
+
191
+ ## Simple one-to-one transfer
192
+
193
+ The older one-to-one file offer, chunk, and completion messages are transient
194
+ too. Replaying a partial run after reconnect cannot reconstruct the missing
195
+ transport state, and retaining hundreds of chunks used to evict actual
196
+ conversation from the history ring. The sender now fails when the transport
197
+ rejects any step, and receivers validate transfer IDs, sender ownership, size,
198
+ chunk count, chunk length, and final sha256 before moving a file into place.
199
+
200
+ The simple path is capped at 256 MiB because the sender buffers the file. A
201
+ sidecar accepts at most 16 incomplete transfers and 512 MiB of promised
202
+ incomplete data. Destinations are chosen without overwriting existing files and
203
+ the final move is exclusive. The browser uses the same wire messages but caps
204
+ in-memory receives at 64 MiB.
205
+
206
+ ## Untrusted wire input
207
+
208
+ The room is not a trust boundary, so parsing is deliberately stricter than the
209
+ TypeScript types:
210
+
211
+ - envelope identity, route, topic, name, and type strings are bounded before an
212
+ envelope is accepted
213
+ - a data connection is bound to its observed stream identity; it cannot start
214
+ sending envelopes as another established peer
215
+ - swarm summaries must have internally consistent size, chunk size, and chunk
216
+ count fields
217
+ - a swarm may describe at most 1,000,000 chunks, each hash must be lowercase
218
+ sha256, and paged manifests are authenticated by a digest over the ordered
219
+ hash list before disk I/O starts
220
+ - malformed messages are rejected locally and do not tear down the room
221
+
222
+ ## Compatibility
223
+
224
+ - The wire envelope format is unchanged.
225
+ - Existing send callbacks that return `void` remain accepted; only an explicit `false` means the transport rejected a send.
226
+ - SDK targets use the SDK's documented `{ uuid }` or `{ streamID }` forms.
227
+ - Existing VDO.Ninja room and signaling behavior remains authoritative.
228
+
229
+ ## Security boundary
230
+
231
+ WebRTC encrypts data in transit. Room names and optional VDO.Ninja passwords control discovery/connection behavior, but they are not an application authorization system. Validate commands at the receiving agent, expose only intentional shared folders, and do not treat peer-supplied names or capabilities as trusted identity claims.