dsh-p2p-sync 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,76 @@
1
- # Temporary Holding Version
1
+ # dsh-p2p-sync
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Direct, serverless peer-to-peer TCP synchronization plugin for [DeepSeek Harness (DSH)](https://deepseek-ai.github.io/dsh/).
4
+
5
+ Keeps two DSH installs (such as an Android phone and a laptop) in sync directly over TCP without cloud storage, coordinators, or relays.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ### From NPM
12
+
13
+ ```bash
14
+ pnpm add dsh-p2p-sync
15
+ # or
16
+ npm install dsh-p2p-sync
17
+ ```
18
+
19
+ ### Local Development / npm link
20
+
21
+ To link and develop locally:
22
+
23
+ ```bash
24
+ cd dsh-p2p-sync
25
+ npm link
26
+ # in your DSH profile directory:
27
+ npm link dsh-p2p-sync
28
+ ```
29
+
30
+ ---
31
+
32
+ ## DSH Bundle Configuration
33
+
34
+ Add the plugin to your profile's `cordis.patch.yml`:
35
+
36
+ ```yaml
37
+ - insert:
38
+ - id: p2p-sync
39
+ name: 'dsh-p2p-sync'
40
+ config:
41
+ port: 7421
42
+ sharedSecret: 'your-shared-secret'
43
+ peers:
44
+ - '100.64.0.2:7421' # e.g. Tailscale or LAN IP
45
+ intervalMs: 300000 # auto-sync every 5 mins (0 to disable)
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Tools
51
+
52
+ Once loaded, the plugin registers four tools on your DSH agent:
53
+
54
+ | Tool | Parameters | Description |
55
+ |---|---|---|
56
+ | `sync_status` | *(none)* | View local device ID, peers, sync status, and pending conflict backups. |
57
+ | `sync_now` | `{ peer?: string, dryRun?: boolean }` | Trigger an immediate sync round (use `dryRun: true` to preview changes). |
58
+ | `sync_peers` | `{ action: "list"\|"add"\|"remove", peer?: string }` | List, add, or remove peer addresses at runtime. |
59
+ | `sync_pending` | `{ peer?: string }` | Inspect sessions and configs that differ right now without applying changes. |
60
+
61
+ ---
62
+
63
+ ## How It Works
64
+
65
+ - **Sessions (`session.v4.jsonl.zstd`)**: Append-only replication tracking high-water sequence marks. Strict contiguity checking prevents gaps. If both devices edit the same session offline, the higher device ID automatically renames its copy to `<id>-fork-<device8>` so zero data is lost.
66
+ - **Config & Preferences**: Reconciled using a Hybrid Logical Clock (HLC) with Last-Write-Wins (LWW). Overwritten local configs are backed up under `sync-state/conflicts/`.
67
+ - **Wire Security**: Peer connections authenticate via an HMAC-SHA256 handshake over exchanged random nonces.
68
+ - **Exclusions**: Secrets (`.credentials.yaml`), machine binaries (`node_modules/`), and runtime state logs are never transmitted.
69
+
70
+ ---
71
+
72
+ ## Testing
73
+
74
+ ```bash
75
+ node --test test/*.mjs
76
+ ```
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: p2p-sync
3
+ name: 'dsh-p2p-sync'
4
+ config: {}
package/lib/hlc.js ADDED
@@ -0,0 +1,74 @@
1
+ export class HLC {
2
+ constructor(deviceId) {
3
+ if (!deviceId) {
4
+ throw new Error('HLC requires a non-empty deviceId');
5
+ }
6
+ this.deviceId = deviceId;
7
+ this.millis = 0;
8
+ this.counter = 0;
9
+ }
10
+
11
+ static pack(millis, counter, device) {
12
+ const mStr = String(millis).padStart(16, '0');
13
+ const cStr = Number(counter).toString(16).padStart(8, '0');
14
+ return `${mStr}${cStr}:${device}`;
15
+ }
16
+
17
+ static unpack(versionStr) {
18
+ if (typeof versionStr !== 'string') {
19
+ throw new Error('Version must be a string');
20
+ }
21
+ const colonIdx = versionStr.indexOf(':');
22
+ if (colonIdx === -1) {
23
+ throw new Error(`Invalid HLC version string: ${versionStr}`);
24
+ }
25
+ const timePart = versionStr.slice(0, colonIdx);
26
+ const device = versionStr.slice(colonIdx + 1);
27
+
28
+ if (timePart.length < 24) {
29
+ throw new Error(`Invalid HLC timestamp length: ${timePart}`);
30
+ }
31
+ const millis = parseInt(timePart.slice(0, 16), 10);
32
+ const counter = parseInt(timePart.slice(16, 24), 16);
33
+
34
+ return { millis, counter, device };
35
+ }
36
+
37
+ static compare(v1, v2) {
38
+ if (v1 === v2) return 0;
39
+ return v1 < v2 ? -1 : 1;
40
+ }
41
+
42
+ pack() {
43
+ return HLC.pack(this.millis, this.counter, this.deviceId);
44
+ }
45
+
46
+ now() {
47
+ const physical = Date.now();
48
+ if (physical > this.millis) {
49
+ this.millis = physical;
50
+ this.counter = 0;
51
+ } else {
52
+ this.counter++;
53
+ }
54
+ return this.pack();
55
+ }
56
+
57
+ observe(remoteVersionStr) {
58
+ const remote = HLC.unpack(remoteVersionStr);
59
+ const physical = Date.now();
60
+ const maxMillis = Math.max(physical, this.millis, remote.millis);
61
+
62
+ if (maxMillis === this.millis && maxMillis === remote.millis) {
63
+ this.counter = Math.max(this.counter, remote.counter) + 1;
64
+ } else if (maxMillis === this.millis) {
65
+ this.counter++;
66
+ } else if (maxMillis === remote.millis) {
67
+ this.counter = remote.counter + 1;
68
+ } else {
69
+ this.counter = 0;
70
+ }
71
+ this.millis = maxMillis;
72
+ return this.pack();
73
+ }
74
+ }
package/lib/index.js ADDED
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import z from '@deepseek-ai/schemastery';
4
+ import { LocalReplica } from './local.js';
5
+ import { PeerManager } from './peer.js';
6
+
7
+ export const name = 'dsh-p2p-sync';
8
+ export const inject = ['web'];
9
+
10
+ export const Config = z.object({
11
+ port: z.number().default(7421).description('TCP listening port for P2P sync (default 7421)'),
12
+ peers: z.array(z.string()).default([]).description('Configured peer addresses (host:port or host)'),
13
+ sharedSecret: z.string().default('').description('Shared secret for HMAC-SHA256 peer authentication'),
14
+ intervalMs: z.number().default(0).description('Background loop interval in ms (0 = disabled)'),
15
+ dshHome: z.string().default('').description('Path to DSH home directory (defaults to DSH_HOME env var)'),
16
+ syncProjectDir: z.boolean().default(false).description('Sync working project directory (default false)'),
17
+ });
18
+
19
+ export async function apply(ctx, config = {}) {
20
+ const dshHome = config.dshHome || process.env.DSH_HOME || process.cwd();
21
+ const localReplica = new LocalReplica(dshHome);
22
+ await localReplica.init();
23
+
24
+ const peerManager = new PeerManager(localReplica, {
25
+ port: config.port ?? 7421,
26
+ peers: config.peers ?? [],
27
+ sharedSecret: config.sharedSecret ?? '',
28
+ intervalMs: config.intervalMs ?? 0,
29
+ });
30
+
31
+ await peerManager.start();
32
+
33
+ if (typeof ctx?.on === 'function') {
34
+ ctx.on('dispose', async () => {
35
+ await peerManager.stop();
36
+ });
37
+ }
38
+
39
+ const toolDefs = [
40
+ {
41
+ name: 'sync_status',
42
+ description: 'Report peers, last round result, per-session high-water marks, and pending conflicts.',
43
+ parameters: { type: 'object', properties: {} },
44
+ async execute() {
45
+ const manifest = await localReplica.getManifest();
46
+ const mgrStatus = peerManager.getStatus();
47
+
48
+ const conflictDir = path.join(dshHome, 'sync-state', 'conflicts');
49
+ const conflicts = fs.existsSync(conflictDir)
50
+ ? fs.readdirSync(conflictDir)
51
+ : [];
52
+
53
+ return {
54
+ deviceId: localReplica.deviceId,
55
+ port: mgrStatus.port,
56
+ peers: mgrStatus.peers,
57
+ peerStatus: mgrStatus.peerStatus,
58
+ activeSyncs: mgrStatus.activeSyncs,
59
+ sessions: manifest.sessions,
60
+ configs: manifest.configs,
61
+ conflicts,
62
+ };
63
+ },
64
+ },
65
+ {
66
+ name: 'sync_now',
67
+ description: 'Force a sync round with a peer (or all peers); supports dryRun returning what would move.',
68
+ parameters: {
69
+ type: 'object',
70
+ properties: {
71
+ peer: { type: 'string', description: 'Peer address (host:port or host). If omitted, syncs with all peers.' },
72
+ dryRun: { type: 'boolean', description: 'If true, simulates the sync and reports what would move.' },
73
+ },
74
+ },
75
+ async execute(args = {}) {
76
+ const targets = args.peer ? [args.peer] : peerManager.peers;
77
+ if (targets.length === 0) {
78
+ return { message: 'No peers configured to sync with' };
79
+ }
80
+
81
+ const results = {};
82
+ for (const target of targets) {
83
+ try {
84
+ results[target] = await peerManager.syncPeer(target, { dryRun: !!args.dryRun });
85
+ } catch (err) {
86
+ results[target] = { error: err.message };
87
+ }
88
+ }
89
+ return results;
90
+ },
91
+ },
92
+ {
93
+ name: 'sync_peers',
94
+ description: 'List, add, or remove configured sync peers.',
95
+ parameters: {
96
+ type: 'object',
97
+ properties: {
98
+ action: { type: 'string', enum: ['list', 'add', 'remove'] },
99
+ peer: { type: 'string', description: 'Peer address to add or remove' },
100
+ },
101
+ },
102
+ async execute(args = {}) {
103
+ const action = args.action || 'list';
104
+ if (action === 'add' && args.peer) {
105
+ const norm = args.peer.trim();
106
+ if (!peerManager.peers.includes(norm)) {
107
+ peerManager.peers.push(norm);
108
+ }
109
+ } else if (action === 'remove' && args.peer) {
110
+ const norm = args.peer.trim();
111
+ peerManager.peers = peerManager.peers.filter((p) => p !== norm);
112
+ }
113
+ return { peers: peerManager.peers };
114
+ },
115
+ },
116
+ {
117
+ name: 'sync_pending',
118
+ description: 'Inspect sessions and configs that differ right now by performing a dryRun comparison with peer.',
119
+ parameters: {
120
+ type: 'object',
121
+ properties: {
122
+ peer: { type: 'string', description: 'Peer address to check against' },
123
+ },
124
+ },
125
+ async execute(args = {}) {
126
+ const target = args.peer || peerManager.peers[0];
127
+ if (!target) {
128
+ return { error: 'No peer specified or configured' };
129
+ }
130
+ try {
131
+ return await peerManager.syncPeer(target, { dryRun: true });
132
+ } catch (err) {
133
+ return { error: err.message };
134
+ }
135
+ },
136
+ },
137
+ ];
138
+
139
+ for (const t of toolDefs) {
140
+ if (ctx?.tools?.register) {
141
+ ctx.tools.register(t);
142
+ }
143
+ // Also bind directly to ctx for direct access
144
+ if (ctx) {
145
+ ctx[t.name] = t.execute;
146
+ }
147
+ }
148
+
149
+ return {
150
+ name,
151
+ localReplica,
152
+ peerManager,
153
+ tools: toolDefs,
154
+ async stop() {
155
+ await peerManager.stop();
156
+ },
157
+ };
158
+ }
159
+
160
+ Object.defineProperty(apply, 'name', { value: name, configurable: true });
161
+ export default Object.assign(apply, { Config, inject });
package/lib/local.js ADDED
@@ -0,0 +1,271 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { HLC } from './hlc.js';
5
+ import {
6
+ decodeSessionLog,
7
+ encodeSessionLog,
8
+ appendEventsToBuffer,
9
+ } from './sessionlog.js';
10
+ import {
11
+ isExcludedPath,
12
+ atomicWriteFile,
13
+ stashConflictFile,
14
+ hashBuffer,
15
+ } from './replica.js';
16
+
17
+ export class LocalReplica {
18
+ constructor(homeDir, deviceId) {
19
+ this.homeDir = path.resolve(homeDir);
20
+ this.customDeviceId = deviceId;
21
+ this.deviceId = null;
22
+ this.hlc = null;
23
+ this.versions = {};
24
+ }
25
+
26
+ async init() {
27
+ const syncStateDir = path.join(this.homeDir, 'sync-state');
28
+ fs.mkdirSync(syncStateDir, { recursive: true });
29
+
30
+ const deviceIdFile = path.join(syncStateDir, 'device-id');
31
+ if (this.customDeviceId) {
32
+ this.deviceId = this.customDeviceId;
33
+ fs.writeFileSync(deviceIdFile, this.deviceId, 'utf8');
34
+ } else if (fs.existsSync(deviceIdFile)) {
35
+ this.deviceId = fs.readFileSync(deviceIdFile, 'utf8').trim();
36
+ } else {
37
+ this.deviceId = crypto.randomBytes(16).toString('hex');
38
+ fs.writeFileSync(deviceIdFile, this.deviceId, 'utf8');
39
+ }
40
+
41
+ this.hlc = new HLC(this.deviceId);
42
+
43
+ const versionsFile = path.join(syncStateDir, 'versions.json');
44
+ if (fs.existsSync(versionsFile)) {
45
+ try {
46
+ this.versions = JSON.parse(fs.readFileSync(versionsFile, 'utf8'));
47
+ } catch {
48
+ this.versions = {};
49
+ }
50
+ }
51
+ }
52
+
53
+ _saveVersions() {
54
+ const versionsFile = path.join(this.homeDir, 'sync-state', 'versions.json');
55
+ atomicWriteFile(versionsFile, Buffer.from(JSON.stringify(this.versions, null, 2), 'utf8'));
56
+ }
57
+
58
+ async getManifest() {
59
+ const sessions = {};
60
+ const configs = {};
61
+
62
+ // 1. Scan sessions: sessions/<projectKey>/<sessionId>/session.v4.jsonl.zstd
63
+ const sessionsDir = path.join(this.homeDir, 'sessions');
64
+ if (fs.existsSync(sessionsDir)) {
65
+ const projectDirs = fs.readdirSync(sessionsDir, { withFileTypes: true });
66
+ for (const pDir of projectDirs) {
67
+ if (!pDir.isDirectory()) continue;
68
+ const projectKey = pDir.name;
69
+ const fullProjPath = path.join(sessionsDir, projectKey);
70
+ const sessionDirs = fs.readdirSync(fullProjPath, { withFileTypes: true });
71
+
72
+ for (const sDir of sessionDirs) {
73
+ if (!sDir.isDirectory()) continue;
74
+ const sessionId = sDir.name;
75
+ const fullSessionDir = path.join(fullProjPath, sessionId);
76
+ const logFile = path.join(fullSessionDir, 'session.v4.jsonl.zstd');
77
+ const lockFile = path.join(fullSessionDir, 'session.lock');
78
+
79
+ if (!fs.existsSync(logFile)) continue;
80
+
81
+ const isLocked = fs.existsSync(lockFile);
82
+ const key = `${projectKey}/${sessionId}`;
83
+
84
+ try {
85
+ const buf = fs.readFileSync(logFile);
86
+ const decoded = decodeSessionLog(buf);
87
+ sessions[key] = {
88
+ seq: decoded.maxSeq,
89
+ sha: decoded.sha256,
90
+ size: buf.length,
91
+ locked: isLocked,
92
+ };
93
+ } catch (err) {
94
+ // If corrupt or unable to read, record with locked or skip
95
+ sessions[key] = {
96
+ seq: -1,
97
+ sha: '',
98
+ size: 0,
99
+ locked: true,
100
+ error: err.message,
101
+ };
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ // 2. Scan configs
108
+ const candidateFiles = [];
109
+
110
+ // profiles/*/package.json and cordis.patch.yml
111
+ const profilesDir = path.join(this.homeDir, 'profiles');
112
+ if (fs.existsSync(profilesDir)) {
113
+ const profEntries = fs.readdirSync(profilesDir, { withFileTypes: true });
114
+ for (const p of profEntries) {
115
+ if (!p.isDirectory() || p.name === 'node_modules') continue;
116
+ const pkgJson = path.join('profiles', p.name, 'package.json');
117
+ const cordisYml = path.join('profiles', p.name, 'cordis.patch.yml');
118
+ candidateFiles.push(pkgJson, cordisYml);
119
+ }
120
+ }
121
+
122
+ candidateFiles.push(
123
+ path.join('android', 'extensions-state.json'),
124
+ path.join('datastore', 'settings.preferences_pb')
125
+ );
126
+
127
+ let versionsChanged = false;
128
+
129
+ for (const rel of candidateFiles) {
130
+ if (isExcludedPath(rel)) continue;
131
+ const fullPath = path.join(this.homeDir, rel);
132
+ if (!fs.existsSync(fullPath)) continue;
133
+
134
+ const stat = fs.statSync(fullPath);
135
+ if (!stat.isFile()) continue;
136
+
137
+ const buf = fs.readFileSync(fullPath);
138
+ const sha = hashBuffer(buf);
139
+
140
+ let version = this.versions[rel]?.version;
141
+ if (!version || this.versions[rel]?.sha !== sha) {
142
+ version = this.hlc.now();
143
+ this.versions[rel] = { version, sha };
144
+ versionsChanged = true;
145
+ }
146
+
147
+ configs[rel.replace(/\\/g, '/')] = {
148
+ version,
149
+ sha,
150
+ size: buf.length,
151
+ };
152
+ }
153
+
154
+ if (versionsChanged) {
155
+ this._saveVersions();
156
+ }
157
+
158
+ return { sessions, configs };
159
+ }
160
+
161
+ async serveSession(key, fromSeq = 0) {
162
+ const logFile = path.join(this.homeDir, 'sessions', key, 'session.v4.jsonl.zstd');
163
+ const lockFile = path.join(this.homeDir, 'sessions', key, 'session.lock');
164
+
165
+ if (fs.existsSync(lockFile)) {
166
+ return { key, locked: true };
167
+ }
168
+
169
+ if (!fs.existsSync(logFile)) {
170
+ throw new Error(`Session log not found: ${key}`);
171
+ }
172
+
173
+ const buf = fs.readFileSync(logFile);
174
+ const decoded = decodeSessionLog(buf);
175
+
176
+ if (fromSeq === 0) {
177
+ return {
178
+ key,
179
+ header: decoded.header,
180
+ events: decoded.events,
181
+ sha: decoded.sha256,
182
+ };
183
+ }
184
+
185
+ return {
186
+ key,
187
+ events: decoded.events.filter((e) => e.seq >= fromSeq),
188
+ sha: decoded.sha256,
189
+ };
190
+ }
191
+
192
+ async serveConfig(relPath) {
193
+ if (isExcludedPath(relPath)) {
194
+ throw new Error(`Attempt to serve excluded config: ${relPath}`);
195
+ }
196
+ const fullPath = path.join(this.homeDir, relPath);
197
+ if (!fs.existsSync(fullPath)) {
198
+ throw new Error(`Config file not found: ${relPath}`);
199
+ }
200
+
201
+ const buf = fs.readFileSync(fullPath);
202
+ const sha = hashBuffer(buf);
203
+ const version = this.versions[relPath]?.version || this.hlc.now();
204
+
205
+ return {
206
+ relPath: relPath.replace(/\\/g, '/'),
207
+ version,
208
+ contentBase64: buf.toString('base64'),
209
+ sha,
210
+ };
211
+ }
212
+
213
+ async applyFork(key, forkedKey) {
214
+ const oldLog = path.join(this.homeDir, 'sessions', key, 'session.v4.jsonl.zstd');
215
+ const newDir = path.join(this.homeDir, 'sessions', forkedKey);
216
+ const newLog = path.join(newDir, 'session.v4.jsonl.zstd');
217
+
218
+ if (fs.existsSync(oldLog)) {
219
+ fs.mkdirSync(newDir, { recursive: true });
220
+ fs.renameSync(oldLog, newLog);
221
+ }
222
+ }
223
+
224
+ async applySession(item) {
225
+ const sessionDir = path.join(this.homeDir, 'sessions', item.key);
226
+ const lockFile = path.join(sessionDir, 'session.lock');
227
+ if (fs.existsSync(lockFile)) {
228
+ return; // Skip locked session
229
+ }
230
+
231
+ const logFile = path.join(sessionDir, 'session.v4.jsonl.zstd');
232
+
233
+ if (item.header) {
234
+ // Full log
235
+ const buf = encodeSessionLog(item.header, item.events || []);
236
+ atomicWriteFile(logFile, buf);
237
+ } else if (item.events && item.events.length > 0) {
238
+ // Incremental append
239
+ if (!fs.existsSync(logFile)) {
240
+ throw new Error(`Cannot append to non-existent session ${item.key}`);
241
+ }
242
+ const existingBuf = fs.readFileSync(logFile);
243
+ const res = appendEventsToBuffer(existingBuf, item.events);
244
+ atomicWriteFile(logFile, res.buffer);
245
+ }
246
+ }
247
+
248
+ async applyConfig(item) {
249
+ if (isExcludedPath(item.relPath)) {
250
+ throw new Error(`Attempt to apply excluded path: ${item.relPath}`);
251
+ }
252
+
253
+ const fullPath = path.join(this.homeDir, item.relPath);
254
+ const buf = Buffer.from(item.contentBase64, 'base64');
255
+ const computedSha = hashBuffer(buf);
256
+
257
+ if (computedSha !== item.sha) {
258
+ throw new Error(`Corrupt payload: sha mismatch for ${item.relPath}`);
259
+ }
260
+
261
+ if (item.stash && fs.existsSync(fullPath)) {
262
+ const device = HLC.unpack(item.version).device;
263
+ stashConflictFile(this.homeDir, item.relPath, device, Date.now());
264
+ }
265
+
266
+ atomicWriteFile(fullPath, buf);
267
+ this.versions[item.relPath] = { version: item.version, sha: item.sha };
268
+ this.hlc.observe(item.version);
269
+ this._saveVersions();
270
+ }
271
+ }
package/lib/merge.js ADDED
@@ -0,0 +1,126 @@
1
+ import { HLC } from './hlc.js';
2
+
3
+ export function planSessions(localSessions = {}, remoteSessions = {}, localDeviceId, remoteDeviceId) {
4
+ const wants = [];
5
+ const forks = [];
6
+ const allKeys = new Set([...Object.keys(localSessions), ...Object.keys(remoteSessions)]);
7
+
8
+ for (const key of allKeys) {
9
+ const local = localSessions[key];
10
+ const remote = remoteSessions[key];
11
+
12
+ // Concurrency safety: check session.lock before touching a session
13
+ if (local?.locked || remote?.locked) {
14
+ continue;
15
+ }
16
+
17
+ if (!local && remote) {
18
+ // Absent locally -> pull full log from 0
19
+ wants.push({ key, from: 0 });
20
+ continue;
21
+ }
22
+
23
+ if (local && !remote) {
24
+ // Present locally, absent remotely -> peer will pull
25
+ continue;
26
+ }
27
+
28
+ // Both present
29
+ if (local.seq === remote.seq) {
30
+ if (local.sha === remote.sha) {
31
+ // In sync
32
+ continue;
33
+ }
34
+
35
+ // Equal seq, different sha -> true divergence -> fork
36
+ if (localDeviceId > remoteDeviceId) {
37
+ const device8 = localDeviceId.slice(0, 8);
38
+ const slashIdx = key.indexOf('/');
39
+ const projectKey = slashIdx !== -1 ? key.slice(0, slashIdx) : '.';
40
+ const sessionId = slashIdx !== -1 ? key.slice(slashIdx + 1) : key;
41
+ const forkedKey = `${projectKey}/${sessionId}-fork-${device8}`;
42
+
43
+ forks.push({ key, forkedKey });
44
+ // Do not pull peer's original in the same round you rename
45
+ }
46
+ // If remote has greater device ID, remote renames its copy, we keep canonical, no pull this round
47
+ continue;
48
+ }
49
+
50
+ if (local.seq < remote.seq) {
51
+ // Pull seq > local
52
+ wants.push({ key, from: local.seq + 1 });
53
+ continue;
54
+ }
55
+
56
+ if (local.seq > remote.seq) {
57
+ // Peer will pull
58
+ continue;
59
+ }
60
+ }
61
+
62
+ return { wants, forks };
63
+ }
64
+
65
+ export function planConfigs(localConfigs = {}, remoteConfigs = {}) {
66
+ const wants = [];
67
+ const stashes = [];
68
+ const metadataUpdates = [];
69
+ const allKeys = new Set([...Object.keys(localConfigs), ...Object.keys(remoteConfigs)]);
70
+
71
+ for (const relPath of allKeys) {
72
+ const local = localConfigs[relPath];
73
+ const remote = remoteConfigs[relPath];
74
+
75
+ if (!local && remote) {
76
+ wants.push(relPath);
77
+ continue;
78
+ }
79
+
80
+ if (local && !remote) {
81
+ // Peer will pull
82
+ continue;
83
+ }
84
+
85
+ // Both present
86
+ const comp = HLC.compare(remote.version, local.version);
87
+
88
+ if (comp > 0) {
89
+ // Remote version is higher (LWW)
90
+ if (remote.sha !== local.sha) {
91
+ wants.push(relPath);
92
+ stashes.push({
93
+ relPath,
94
+ remoteVersion: remote.version,
95
+ remoteSha: remote.sha,
96
+ });
97
+ } else {
98
+ metadataUpdates.push({ relPath, version: remote.version });
99
+ }
100
+ continue;
101
+ }
102
+
103
+ if (comp < 0) {
104
+ // Local version is higher -> peer will pull
105
+ continue;
106
+ }
107
+
108
+ // comp === 0: equal versions
109
+ if (remote.sha === local.sha) {
110
+ // Identical
111
+ continue;
112
+ }
113
+
114
+ // Equal versions with different content: break tie on sha
115
+ if (remote.sha > local.sha) {
116
+ wants.push(relPath);
117
+ stashes.push({
118
+ relPath,
119
+ remoteVersion: remote.version,
120
+ remoteSha: remote.sha,
121
+ });
122
+ }
123
+ }
124
+
125
+ return { wants, stashes, metadataUpdates };
126
+ }
package/lib/peer.js ADDED
@@ -0,0 +1,182 @@
1
+ import net from 'node:net';
2
+ import crypto from 'node:crypto';
3
+ import { Channel } from './wire.js';
4
+ import { executeSyncRound } from './sync.js';
5
+
6
+ export async function authenticateHandshake(channel, sharedSecret) {
7
+ if (!sharedSecret) {
8
+ return true;
9
+ }
10
+
11
+ const localNonce = crypto.randomBytes(32).toString('hex');
12
+ await channel.send({ type: 'auth-challenge', nonce: localNonce });
13
+
14
+ const remoteChallenge = await channel.receive('auth-challenge');
15
+ const remoteNonce = remoteChallenge.nonce;
16
+
17
+ const proof = crypto
18
+ .createHmac('sha256', sharedSecret)
19
+ .update(localNonce + remoteNonce)
20
+ .digest('hex');
21
+
22
+ const expectedRemoteProof = crypto
23
+ .createHmac('sha256', sharedSecret)
24
+ .update(remoteNonce + localNonce)
25
+ .digest('hex');
26
+
27
+ await channel.send({ type: 'auth-response', proof });
28
+
29
+ const remoteResponse = await channel.receive('auth-response');
30
+ if (remoteResponse.proof !== expectedRemoteProof) {
31
+ throw new Error('Authentication failed: proof mismatch');
32
+ }
33
+
34
+ return true;
35
+ }
36
+
37
+ export function parsePeerAddress(peerStr, defaultPort = 7421) {
38
+ const str = peerStr.trim();
39
+ const colonIdx = str.lastIndexOf(':');
40
+ if (colonIdx === -1) {
41
+ return { host: str, port: defaultPort };
42
+ }
43
+ const host = str.slice(0, colonIdx);
44
+ const port = parseInt(str.slice(colonIdx + 1), 10);
45
+ return { host, port: Number.isNaN(port) ? defaultPort : port };
46
+ }
47
+
48
+ export class PeerManager {
49
+ constructor(localReplica, config = {}) {
50
+ this.localReplica = localReplica;
51
+ this.port = config.port || 7421;
52
+ this.peers = Array.isArray(config.peers) ? [...config.peers] : [];
53
+ this.sharedSecret = config.sharedSecret || '';
54
+ this.intervalMs = config.intervalMs || 0;
55
+ this.server = null;
56
+ this.timer = null;
57
+ this.activeSyncs = new Set();
58
+ this.peerStatus = new Map();
59
+ }
60
+
61
+ async start() {
62
+ await this.startListener();
63
+ if (this.intervalMs > 0) {
64
+ this.timer = setInterval(() => {
65
+ this.runScheduledRound().catch(() => {});
66
+ }, this.intervalMs);
67
+ }
68
+ }
69
+
70
+ async stop() {
71
+ if (this.timer) {
72
+ clearInterval(this.timer);
73
+ this.timer = null;
74
+ }
75
+ if (this.server) {
76
+ await new Promise((resolve) => this.server.close(resolve));
77
+ this.server = null;
78
+ }
79
+ }
80
+
81
+ async startListener() {
82
+ return new Promise((resolve, reject) => {
83
+ const srv = net.createServer(async (socket) => {
84
+ const remoteAddr = `${socket.remoteAddress}:${socket.remotePort}`;
85
+ const channel = new Channel(socket);
86
+ try {
87
+ await authenticateHandshake(channel, this.sharedSecret);
88
+ await executeSyncRound(channel, this.localReplica);
89
+ } catch {
90
+ // Socket error or sync fail
91
+ } finally {
92
+ channel.close();
93
+ }
94
+ });
95
+
96
+ srv.on('error', (err) => {
97
+ reject(err);
98
+ });
99
+
100
+ srv.listen(this.port, () => {
101
+ this.server = srv;
102
+ resolve();
103
+ });
104
+ });
105
+ }
106
+
107
+ async syncPeer(peerStr, opts = {}) {
108
+ const norm = peerStr.trim();
109
+ if (this.activeSyncs.has(norm)) {
110
+ return { skipped: 'concurrent_sync_in_progress' };
111
+ }
112
+
113
+ this.activeSyncs.add(norm);
114
+ const { host, port } = parsePeerAddress(norm, this.port);
115
+
116
+ try {
117
+ const socket = await new Promise((resolve, reject) => {
118
+ const s = net.connect({ host, port }, () => resolve(s));
119
+ s.once('error', reject);
120
+ });
121
+
122
+ const channel = new Channel(socket);
123
+ try {
124
+ await authenticateHandshake(channel, this.sharedSecret);
125
+ const result = await executeSyncRound(channel, this.localReplica, opts);
126
+
127
+ const status = this.peerStatus.get(norm) || {};
128
+ this.peerStatus.set(norm, {
129
+ ...status,
130
+ lastSuccess: Date.now(),
131
+ lastError: null,
132
+ backoffMs: 1000,
133
+ });
134
+
135
+ return result;
136
+ } finally {
137
+ channel.close();
138
+ }
139
+ } catch (err) {
140
+ const status = this.peerStatus.get(norm) || {};
141
+ const nextBackoff = Math.min((status.backoffMs || 1000) * 2, 60000);
142
+ this.peerStatus.set(norm, {
143
+ ...status,
144
+ lastError: err.message,
145
+ lastFailure: Date.now(),
146
+ backoffMs: nextBackoff,
147
+ });
148
+ throw err;
149
+ } finally {
150
+ this.activeSyncs.delete(norm);
151
+ }
152
+ }
153
+
154
+ async runScheduledRound() {
155
+ const now = Date.now();
156
+ for (const peer of this.peers) {
157
+ const status = this.peerStatus.get(peer);
158
+ if (status?.lastFailure && status?.backoffMs) {
159
+ if (now - status.lastFailure < status.backoffMs) {
160
+ continue; // In backoff period
161
+ }
162
+ }
163
+ this.syncPeer(peer).catch(() => {});
164
+ }
165
+ }
166
+
167
+ getStatus() {
168
+ const peerStatuses = {};
169
+ for (const peer of this.peers) {
170
+ peerStatuses[peer] = this.peerStatus.get(peer) || {
171
+ lastSuccess: null,
172
+ lastError: null,
173
+ };
174
+ }
175
+ return {
176
+ port: this.port,
177
+ peers: this.peers,
178
+ activeSyncs: Array.from(this.activeSyncs),
179
+ peerStatus: peerStatuses,
180
+ };
181
+ }
182
+ }
package/lib/replica.js ADDED
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+
5
+ export function isExcludedPath(relPath) {
6
+ const norm = relPath.replace(/\\/g, '/');
7
+
8
+ if (norm === '.credentials.yaml' || norm.endsWith('/.credentials.yaml')) return true;
9
+ if (norm.includes('node_modules')) return true;
10
+ if (norm === 'android/app-layer.patch.yml') return true;
11
+ if (norm === 'android/budget.json') return true;
12
+ if (norm === 'android/boot-journal.json') return true;
13
+ if (norm === 'android/pnpm-report.jsonl') return true;
14
+ if (norm.startsWith('logs/') || norm.includes('/logs/')) return true;
15
+ if (norm.startsWith('storages/session_projcache/') || norm.includes('/storages/session_projcache/')) return true;
16
+ if (norm.endsWith('session.lock')) return true;
17
+
18
+ return false;
19
+ }
20
+
21
+ export function atomicWriteFile(targetPath, buffer) {
22
+ const dir = path.dirname(targetPath);
23
+ fs.mkdirSync(dir, { recursive: true });
24
+
25
+ const tmpPath = `${targetPath}.${process.pid}.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
26
+ fs.writeFileSync(tmpPath, buffer);
27
+ fs.renameSync(tmpPath, targetPath);
28
+ }
29
+
30
+ export function stashConflictFile(homeDir, relPath, device = 'remote', stamp = Date.now()) {
31
+ const sourcePath = path.join(homeDir, relPath);
32
+ if (!fs.existsSync(sourcePath)) {
33
+ return null;
34
+ }
35
+
36
+ const safeName = relPath.replace(/[/\\]/g, '_');
37
+ const conflictDir = path.join(homeDir, 'sync-state', 'conflicts');
38
+ fs.mkdirSync(conflictDir, { recursive: true });
39
+
40
+ const destPath = path.join(conflictDir, `${safeName}.${device}.${stamp}.bak`);
41
+ fs.copyFileSync(sourcePath, destPath);
42
+ return destPath;
43
+ }
44
+
45
+ export function hashBuffer(buf) {
46
+ return createHash('sha256').update(buf).digest('hex');
47
+ }
@@ -0,0 +1,87 @@
1
+ import { zstdCompressSync, zstdDecompressSync } from 'node:zlib';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ export class SessionLogContiguityError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'SessionLogContiguityError';
8
+ }
9
+ }
10
+
11
+ export function hashBuffer(buf) {
12
+ return createHash('sha256').update(buf).digest('hex');
13
+ }
14
+
15
+ export function validateEventContiguity(events, expectedStartSeq = 0) {
16
+ for (let i = 0; i < events.length; i++) {
17
+ const expected = expectedStartSeq + i;
18
+ if (events[i].seq !== expected) {
19
+ throw new SessionLogContiguityError(
20
+ `Non-contiguous event seq: expected ${expected}, got ${events[i].seq}`
21
+ );
22
+ }
23
+ }
24
+ }
25
+
26
+ export function encodeSessionLog(header, events = []) {
27
+ validateEventContiguity(events, 0);
28
+ const lines = [JSON.stringify(header)];
29
+ for (const ev of events) {
30
+ lines.push(JSON.stringify(ev));
31
+ }
32
+ const payload = Buffer.from(lines.join('\n') + '\n', 'utf8');
33
+ return zstdCompressSync(payload);
34
+ }
35
+
36
+ export function decodeSessionLog(compressedBuffer) {
37
+ const decompressed = zstdDecompressSync(compressedBuffer);
38
+ const text = decompressed.toString('utf8');
39
+ const rawLines = text.split('\n').filter((line) => line.trim().length > 0);
40
+ if (rawLines.length === 0) {
41
+ throw new Error('Empty session log');
42
+ }
43
+
44
+ const header = JSON.parse(rawLines[0]);
45
+ const events = [];
46
+ for (let i = 1; i < rawLines.length; i++) {
47
+ events.push(JSON.parse(rawLines[i]));
48
+ }
49
+
50
+ validateEventContiguity(events, 0);
51
+
52
+ const maxSeq = events.length > 0 ? events[events.length - 1].seq : -1;
53
+ const sha256 = hashBuffer(compressedBuffer);
54
+
55
+ return {
56
+ header,
57
+ events,
58
+ maxSeq,
59
+ sha256,
60
+ uncompressedBytes: decompressed.length,
61
+ };
62
+ }
63
+
64
+ export function appendEventsToBuffer(existingCompressedBuffer, newEvents) {
65
+ if (!newEvents || newEvents.length === 0) {
66
+ return {
67
+ buffer: existingCompressedBuffer,
68
+ maxSeq: decodeSessionLog(existingCompressedBuffer).maxSeq,
69
+ sha256: hashBuffer(existingCompressedBuffer),
70
+ };
71
+ }
72
+
73
+ const decoded = decodeSessionLog(existingCompressedBuffer);
74
+ const expectedStart = decoded.maxSeq + 1;
75
+ validateEventContiguity(newEvents, expectedStart);
76
+
77
+ const combinedEvents = [...decoded.events, ...newEvents];
78
+ const buffer = encodeSessionLog(decoded.header, combinedEvents);
79
+ const maxSeq = combinedEvents[combinedEvents.length - 1].seq;
80
+ const sha256 = hashBuffer(buffer);
81
+
82
+ return {
83
+ buffer,
84
+ maxSeq,
85
+ sha256,
86
+ };
87
+ }
package/lib/sync.js ADDED
@@ -0,0 +1,119 @@
1
+ import { planSessions, planConfigs } from './merge.js';
2
+
3
+ export async function executeSyncRound(channel, localReplica, opts = {}) {
4
+ // 1. Hello
5
+ await channel.send({
6
+ type: 'hello',
7
+ protocol: 'dsh-p2p-v1',
8
+ deviceId: localReplica.deviceId,
9
+ label: opts.label || '',
10
+ });
11
+
12
+ const remoteHello = await channel.receive('hello');
13
+ if (remoteHello.protocol !== 'dsh-p2p-v1') {
14
+ throw new Error(`Protocol mismatch: expected dsh-p2p-v1, got ${remoteHello.protocol}`);
15
+ }
16
+ if (remoteHello.deviceId === localReplica.deviceId) {
17
+ throw new Error('Cannot sync with self: identical deviceId');
18
+ }
19
+
20
+ // 2. Manifest
21
+ const localManifest = await localReplica.getManifest();
22
+ await channel.send({
23
+ type: 'manifest',
24
+ sessions: localManifest.sessions,
25
+ configs: localManifest.configs,
26
+ });
27
+
28
+ const remoteManifest = await channel.receive('manifest');
29
+
30
+ // 3. Wants
31
+ const sessionPlan = planSessions(
32
+ localManifest.sessions,
33
+ remoteManifest.sessions,
34
+ localReplica.deviceId,
35
+ remoteHello.deviceId
36
+ );
37
+ const configPlan = planConfigs(localManifest.configs, remoteManifest.configs);
38
+
39
+ // Apply any planned forks before data transfer
40
+ for (const fork of sessionPlan.forks) {
41
+ await localReplica.applyFork(fork.key, fork.forkedKey);
42
+ }
43
+
44
+ if (opts.dryRun) {
45
+ await channel.send({ type: 'bye' });
46
+ await channel.receive('bye');
47
+ return {
48
+ dryRun: true,
49
+ remoteDeviceId: remoteHello.deviceId,
50
+ sessionWants: sessionPlan.wants,
51
+ configWants: configPlan.wants,
52
+ forks: sessionPlan.forks,
53
+ };
54
+ }
55
+
56
+ await channel.send({
57
+ type: 'wants',
58
+ sessions: sessionPlan.wants,
59
+ configs: configPlan.wants,
60
+ });
61
+
62
+ const remoteWants = await channel.receive('wants');
63
+
64
+ // 4. Serve & Receive
65
+ const numSessionsToReceive = sessionPlan.wants.length;
66
+ const numConfigsToReceive = configPlan.wants.length;
67
+
68
+ const serveTask = async () => {
69
+ for (const sWant of remoteWants.sessions || []) {
70
+ const item = await localReplica.serveSession(sWant.key, sWant.from);
71
+ await channel.send({ type: 'serve-session', item });
72
+ }
73
+ for (const cWant of remoteWants.configs || []) {
74
+ const item = await localReplica.serveConfig(cWant);
75
+ await channel.send({ type: 'serve-config', item });
76
+ }
77
+ await channel.send({ type: 'serve-end' });
78
+ };
79
+
80
+ const receiveTask = async () => {
81
+ const receivedSessions = [];
82
+ for (let i = 0; i < numSessionsToReceive; i++) {
83
+ const msg = await channel.receive('serve-session');
84
+ receivedSessions.push(msg.item);
85
+ }
86
+ const receivedConfigs = [];
87
+ for (let i = 0; i < numConfigsToReceive; i++) {
88
+ const msg = await channel.receive('serve-config');
89
+ receivedConfigs.push(msg.item);
90
+ }
91
+ await channel.receive('serve-end');
92
+ return { receivedSessions, receivedConfigs };
93
+ };
94
+
95
+ const [, { receivedSessions, receivedConfigs }] = await Promise.all([
96
+ serveTask(),
97
+ receiveTask(),
98
+ ]);
99
+
100
+ // 5. Apply, save state, bye
101
+ for (const item of receivedSessions) {
102
+ await localReplica.applySession(item);
103
+ }
104
+
105
+ for (const item of receivedConfigs) {
106
+ const shouldStash = configPlan.stashes.some((s) => s.relPath === item.relPath);
107
+ await localReplica.applyConfig({ ...item, stash: shouldStash });
108
+ }
109
+
110
+ await channel.send({ type: 'bye' });
111
+ await channel.receive('bye');
112
+
113
+ return {
114
+ remoteDeviceId: remoteHello.deviceId,
115
+ sessionsReceived: receivedSessions.length,
116
+ configsReceived: receivedConfigs.length,
117
+ forks: sessionPlan.forks.length,
118
+ };
119
+ }
package/lib/wire.js ADDED
@@ -0,0 +1,185 @@
1
+ export const MAX_FRAME_SIZE = 64 * 1024 * 1024; // 64 MiB
2
+
3
+ export class WireError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'WireError';
7
+ }
8
+ }
9
+
10
+ export function encodeFrame(message) {
11
+ const jsonStr = JSON.stringify(message);
12
+ const payload = Buffer.from(jsonStr, 'utf8');
13
+ if (payload.length > MAX_FRAME_SIZE) {
14
+ throw new WireError(`Frame size ${payload.length} exceeds maximum cap of ${MAX_FRAME_SIZE} bytes`);
15
+ }
16
+ const frame = Buffer.allocUnsafe(4 + payload.length);
17
+ frame.writeUInt32BE(payload.length, 0);
18
+ payload.copy(frame, 4);
19
+ return frame;
20
+ }
21
+
22
+ export class Channel {
23
+ constructor(stream, opts = {}) {
24
+ this.stream = stream;
25
+ this.defaultTimeoutMs = opts.timeoutMs ?? 120000;
26
+ this.incomingBuffer = Buffer.alloc(0);
27
+ this.frameQueue = [];
28
+ this.receivers = [];
29
+ this.closed = false;
30
+ this.fatalError = null;
31
+
32
+ this._onData = (chunk) => this._handleData(chunk);
33
+ this._onError = (err) => this._fatal(err);
34
+ this._onEnd = () => this._handleClose();
35
+ this._onClose = () => this._handleClose();
36
+
37
+ this.stream.on('data', this._onData);
38
+ this.stream.on('error', this._onError);
39
+ this.stream.on('end', this._onEnd);
40
+ this.stream.on('close', this._onClose);
41
+ }
42
+
43
+ _handleData(chunk) {
44
+ this.incomingBuffer = Buffer.concat([this.incomingBuffer, chunk]);
45
+
46
+ while (this.incomingBuffer.length >= 4) {
47
+ const frameLen = this.incomingBuffer.readUInt32BE(0);
48
+ if (frameLen > MAX_FRAME_SIZE) {
49
+ this._fatal(new WireError(`Frame size ${frameLen} exceeds maximum cap of ${MAX_FRAME_SIZE} bytes`));
50
+ return;
51
+ }
52
+
53
+ if (this.incomingBuffer.length < 4 + frameLen) {
54
+ // Need more bytes
55
+ break;
56
+ }
57
+
58
+ const payload = this.incomingBuffer.subarray(4, 4 + frameLen);
59
+ this.incomingBuffer = this.incomingBuffer.subarray(4 + frameLen);
60
+
61
+ let message;
62
+ try {
63
+ message = JSON.parse(payload.toString('utf8'));
64
+ } catch (err) {
65
+ this._fatal(new WireError(`Failed to parse JSON frame: ${err.message}`));
66
+ return;
67
+ }
68
+
69
+ this._dispatchMessage(message);
70
+ }
71
+ }
72
+
73
+ _dispatchMessage(message) {
74
+ if (this.receivers.length > 0) {
75
+ const receiver = this.receivers.shift();
76
+ clearTimeout(receiver.timer);
77
+ if (receiver.expectedType && message.type !== receiver.expectedType) {
78
+ receiver.reject(new WireError(
79
+ `Unexpected frame type: got ${message.type}, expected ${receiver.expectedType}`
80
+ ));
81
+ } else {
82
+ receiver.resolve(message);
83
+ }
84
+ } else {
85
+ this.frameQueue.push(message);
86
+ }
87
+ }
88
+
89
+ _fatal(err) {
90
+ this.fatalError = err instanceof WireError ? err : new WireError(err.message);
91
+ this.close();
92
+ }
93
+
94
+ _handleClose() {
95
+ this.closed = true;
96
+ const err = this.fatalError || new WireError('Channel closed');
97
+ while (this.receivers.length > 0) {
98
+ const receiver = this.receivers.shift();
99
+ clearTimeout(receiver.timer);
100
+ receiver.reject(err);
101
+ }
102
+ }
103
+
104
+ async send(message) {
105
+ if (this.closed) {
106
+ throw this.fatalError || new WireError('Cannot send on closed channel');
107
+ }
108
+ const frame = encodeFrame(message);
109
+ if (!this.stream.write(frame)) {
110
+ await new Promise((resolve, reject) => {
111
+ const onDrain = () => {
112
+ cleanup();
113
+ resolve();
114
+ };
115
+ const onError = (err) => {
116
+ cleanup();
117
+ reject(err);
118
+ };
119
+ const cleanup = () => {
120
+ this.stream.removeListener('drain', onDrain);
121
+ this.stream.removeListener('error', onError);
122
+ };
123
+ this.stream.once('drain', onDrain);
124
+ this.stream.once('error', onError);
125
+ });
126
+ }
127
+ }
128
+
129
+ receive(expectedType, timeoutMs) {
130
+ if (this.fatalError) {
131
+ return Promise.reject(this.fatalError);
132
+ }
133
+
134
+ // Check if frame already queued
135
+ if (this.frameQueue.length > 0) {
136
+ const message = this.frameQueue.shift();
137
+ if (expectedType && message.type !== expectedType) {
138
+ return Promise.reject(new WireError(
139
+ `Unexpected frame type: got ${message.type}, expected ${expectedType}`
140
+ ));
141
+ }
142
+ return Promise.resolve(message);
143
+ }
144
+
145
+ if (this.closed) {
146
+ return Promise.reject(new WireError('Channel closed'));
147
+ }
148
+
149
+ const effectiveTimeout = timeoutMs ?? this.defaultTimeoutMs;
150
+
151
+ return new Promise((resolve, reject) => {
152
+ let timer = null;
153
+ if (effectiveTimeout > 0) {
154
+ timer = setTimeout(() => {
155
+ const idx = this.receivers.findIndex((r) => r.resolve === resolve);
156
+ if (idx !== -1) {
157
+ this.receivers.splice(idx, 1);
158
+ }
159
+ reject(new WireError(`Timed out waiting for frame (${expectedType ?? 'any'}) after ${effectiveTimeout}ms`));
160
+ }, effectiveTimeout);
161
+ }
162
+
163
+ this.receivers.push({
164
+ resolve,
165
+ reject,
166
+ expectedType,
167
+ timer,
168
+ });
169
+ });
170
+ }
171
+
172
+ close() {
173
+ if (!this.closed) {
174
+ this.closed = true;
175
+ this.stream.removeListener('data', this._onData);
176
+ this.stream.removeListener('error', this._onError);
177
+ this.stream.removeListener('end', this._onEnd);
178
+ this.stream.removeListener('close', this._onClose);
179
+ if (typeof this.stream.destroy === 'function') {
180
+ this.stream.destroy();
181
+ }
182
+ this._handleClose();
183
+ }
184
+ }
185
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "dsh-p2p-sync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Direct peer-to-peer sync for DeepSeek Harness (no cloud, no coordinator).",
6
+ "main": "lib/index.js",
7
+ "files": [
8
+ "lib",
9
+ "cordis.patch.yml",
10
+ "README.md"
11
+ ],
12
+ "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ }
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/oiupoyt/dsh-p2p-sync.git"
23
+ },
24
+ "dependencies": {
25
+ "@deepseek-ai/schemastery": "~3.19.0"
26
+ }
27
+ }