agent-yes 1.231.0 → 1.232.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/dist/{SUPPORTED_CLIS-Cj00WOEz.js → SUPPORTED_CLIS-BP1BBtWO.js} +3 -3
- package/dist/{SUPPORTED_CLIS-BS2hYjrW.js → SUPPORTED_CLIS-D6J7QvLL.js} +2 -2
- package/dist/{agentShare-BBLXfmbU.js → agentShare-CDMFg0Rj.js} +2 -2
- package/dist/{callback-B8Bx8c-9.js → callback-B1kcLI1k.js} +3 -3
- package/dist/{callback-beueK8jU.js → callback-sBpq-n_6.js} +2 -2
- package/dist/{channels-mAf4ztTJ.js → channels-Boi4aNiY.js} +24 -6
- package/dist/{channels-CUOl2wrG.js → channels-Bu4DZzHk.js} +15 -2
- package/dist/channels.js +19 -3
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-CJfxTj46.js → notifyDaemon-BNCj9i2N.js} +2 -2
- package/dist/{rustBinary-d4ZUStzi.js → rustBinary-D8_DgkiI.js} +2 -2
- package/dist/{schedule-B4v3unjv.js → schedule-BVVQtiaS.js} +4 -4
- package/dist/{serve-Bf8JJcJG.js → serve-Bv_twI1k.js} +13 -13
- package/dist/{setup-Cth_2Kor.js → setup-BmoiDPZn.js} +2 -2
- package/dist/{subcommands-CFg6By2s.js → subcommands-CFC5HMmx.js} +1 -1
- package/dist/{subcommands-t140aQXd.js → subcommands-CXCoOuN6.js} +9 -9
- package/dist/{ts-BDMKe8RP.js → ts-Bb3CeS6-.js} +2 -2
- package/dist/{versionChecker-Bhv8OZAY.js → versionChecker-SHk3Fuvm.js} +2 -2
- package/dist/{ws-6kyIFJHL.js → ws-DDYVVQwl.js} +2 -2
- package/package.json +3 -1
- package/ts/channels/browser.ts +287 -0
- package/ts/channels/hlc.ts +67 -0
- package/ts/channels/index.ts +10 -0
- package/ts/channels/link.ts +103 -0
- package/ts/channels/op.ts +89 -0
- package/ts/channels/peer.ts +468 -0
- package/ts/channels/store.browser.ts +42 -0
- package/ts/channels/store.node.ts +72 -0
- package/ts/channels/store.ts +170 -0
- package/ts/channels.spec.ts +23 -0
- package/ts/channels.ts +50 -2
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// Isomorphic WebRTC mesh peer for ONE channel (Node/Bun AND browser).
|
|
2
|
+
//
|
|
3
|
+
// Unlike the share bridge (ts/share.ts), a channel is symmetric: there is no
|
|
4
|
+
// host, every participant holds a full replica, and BOTH ends of each pairwise
|
|
5
|
+
// DataChannel send application traffic (chat ops). The transport is injected —
|
|
6
|
+
// Node wires node-datachannel + Cloudflare TURN (via share.ts), the browser
|
|
7
|
+
// wires its native RTCPeerConnection/WebSocket — so this one class runs both
|
|
8
|
+
// sides. It reuses the E2E sealed frames + mandatory bidirectional key-
|
|
9
|
+
// confirmation handshake from lab/ui/e2e.js verbatim.
|
|
10
|
+
//
|
|
11
|
+
// Topology: the signaling Room DO runs in mesh mode (lab/ui/cf/worker.ts) and
|
|
12
|
+
// relays offer/answer/candidate between any two peers plus broadcasts
|
|
13
|
+
// peer-join/leave. Each pair forms one DataChannel; the peer with the smaller id
|
|
14
|
+
// is the offerer (deterministic, avoids offer glare). On connect the two run
|
|
15
|
+
// anti-entropy (exchange have-vectors, send the diff); new ops broadcast to all
|
|
16
|
+
// confirmed peers, and an op that is new to our replica is re-gossiped to every
|
|
17
|
+
// OTHER peer — dedup by id makes that loop-free and convergent even over a
|
|
18
|
+
// partial mesh.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
CONFIRM_TIMEOUT_MS,
|
|
22
|
+
FLAG_CONFIRM,
|
|
23
|
+
computeTranscriptHash,
|
|
24
|
+
deriveAuthToken,
|
|
25
|
+
deriveDirKeys,
|
|
26
|
+
open as e2eOpen,
|
|
27
|
+
seal as e2eSeal,
|
|
28
|
+
packEnvelope,
|
|
29
|
+
randomHex,
|
|
30
|
+
unpackEnvelope,
|
|
31
|
+
} from "../../lab/ui/e2e.js";
|
|
32
|
+
import { isValidOp, type Op } from "./op.ts";
|
|
33
|
+
import { haveVector, opsMissing } from "./store.ts";
|
|
34
|
+
|
|
35
|
+
const SIGNAL_SUBPROTOCOL = "ay-signal-1";
|
|
36
|
+
const HEARTBEAT_MS = 20_000; // keepalive ping to the rendezvous (edge auto-pongs)
|
|
37
|
+
const DC_LABEL = "ch";
|
|
38
|
+
const STUN = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
39
|
+
|
|
40
|
+
export type IceServer = { urls: string | string[]; username?: string; credential?: string };
|
|
41
|
+
|
|
42
|
+
/** Persistence the peer drives — Node supplies a jsonl adapter, the browser LocalStorage. */
|
|
43
|
+
export interface ChannelPeerStore {
|
|
44
|
+
all(): Promise<Op[]>;
|
|
45
|
+
/** Merge + persist; returns only the ops that were genuinely new. */
|
|
46
|
+
append(ops: Op[]): Promise<Op[]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ChannelPeerOpts {
|
|
50
|
+
room: string;
|
|
51
|
+
sighost: string;
|
|
52
|
+
/** Secret S (64-hex) — derives the authToken the server sees + the AES keys it never does. */
|
|
53
|
+
s: string;
|
|
54
|
+
store: ChannelPeerStore;
|
|
55
|
+
/** RTCPeerConnection constructor — node-datachannel/polyfill (Node) or the browser global. */
|
|
56
|
+
rtc: new (config?: any) => any;
|
|
57
|
+
/** ICE servers provider (TURN+STUN). Defaults to public STUN. */
|
|
58
|
+
iceServers?: () => Promise<IceServer[]>;
|
|
59
|
+
/** WebSocket constructor. Defaults to the global. */
|
|
60
|
+
WebSocketImpl?: typeof WebSocket;
|
|
61
|
+
/** Called for each op newly added to the replica (live tail / UI). */
|
|
62
|
+
onOp?: (op: Op) => void;
|
|
63
|
+
/** Called when the confirmed-peer count changes (presence). */
|
|
64
|
+
onPeers?: (count: number) => void;
|
|
65
|
+
log?: (msg: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Conn {
|
|
69
|
+
peerId: string;
|
|
70
|
+
offerer: boolean;
|
|
71
|
+
pc: any;
|
|
72
|
+
dc: any;
|
|
73
|
+
keyEnc?: CryptoKey;
|
|
74
|
+
keyDec?: CryptoKey;
|
|
75
|
+
th?: Uint8Array;
|
|
76
|
+
localSdp?: string;
|
|
77
|
+
remoteSdp?: string;
|
|
78
|
+
pendingCandidates: any[];
|
|
79
|
+
send: { sendCtr: bigint };
|
|
80
|
+
recv: { lastSeen: bigint };
|
|
81
|
+
myNonce: string;
|
|
82
|
+
confirmedIn: boolean;
|
|
83
|
+
confirmedOut: boolean;
|
|
84
|
+
confirmed: boolean;
|
|
85
|
+
confirmStarted: boolean;
|
|
86
|
+
confirmTimer?: ReturnType<typeof setTimeout>;
|
|
87
|
+
sendChain: Promise<void>;
|
|
88
|
+
recvChain: Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class ChannelPeer {
|
|
92
|
+
private ws?: WebSocket;
|
|
93
|
+
private myId = "";
|
|
94
|
+
private authToken = "";
|
|
95
|
+
private RTCPeerConnection: new (config?: any) => any;
|
|
96
|
+
private WS: typeof WebSocket;
|
|
97
|
+
private conns = new Map<string, Conn>();
|
|
98
|
+
private heartbeat?: ReturnType<typeof setInterval>;
|
|
99
|
+
private closed = false;
|
|
100
|
+
|
|
101
|
+
constructor(private opts: ChannelPeerOpts) {
|
|
102
|
+
this.RTCPeerConnection = opts.rtc;
|
|
103
|
+
this.WS = opts.WebSocketImpl ?? WebSocket;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private ice(): Promise<IceServer[]> {
|
|
107
|
+
return this.opts.iceServers ? this.opts.iceServers() : Promise.resolve(STUN);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Connect to signaling and start meshing. Resolves once the socket is open. */
|
|
111
|
+
async start(): Promise<void> {
|
|
112
|
+
this.authToken = await deriveAuthToken(this.opts.s, this.opts.room, this.opts.sighost);
|
|
113
|
+
await this.connectSignaling();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
close(): void {
|
|
117
|
+
this.closed = true;
|
|
118
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
119
|
+
// Materialize ids first — dropConn mutates the map we're iterating.
|
|
120
|
+
const ids = Array.from(this.conns.keys());
|
|
121
|
+
for (const id of ids) this.dropConn(id);
|
|
122
|
+
try {
|
|
123
|
+
this.ws?.close();
|
|
124
|
+
} catch {
|
|
125
|
+
/* already closed */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Publish a locally-authored op: persist, then broadcast to every confirmed peer. */
|
|
130
|
+
async publish(op: Op): Promise<void> {
|
|
131
|
+
await this.opts.store.append([op]);
|
|
132
|
+
this.broadcast({ t: "op", op });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private log(msg: string) {
|
|
136
|
+
this.opts.log?.(`[ch:peer] ${msg}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private wsUrl(): string {
|
|
140
|
+
const scheme =
|
|
141
|
+
this.opts.sighost.startsWith("localhost") || this.opts.sighost.startsWith("127.")
|
|
142
|
+
? "ws"
|
|
143
|
+
: "wss";
|
|
144
|
+
return `${scheme}://${this.opts.sighost}/${this.opts.room}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async connectSignaling(): Promise<void> {
|
|
148
|
+
await new Promise<void>((resolve, reject) => {
|
|
149
|
+
const ws = new this.WS(this.wsUrl(), [SIGNAL_SUBPROTOCOL]);
|
|
150
|
+
this.ws = ws;
|
|
151
|
+
let opened = false;
|
|
152
|
+
ws.onopen = () => {
|
|
153
|
+
opened = true;
|
|
154
|
+
ws.send(
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
type: "hello",
|
|
157
|
+
role: "client",
|
|
158
|
+
v: 2,
|
|
159
|
+
mesh: true,
|
|
160
|
+
token: this.authToken,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
this.heartbeat = setInterval(() => {
|
|
164
|
+
try {
|
|
165
|
+
if (ws.readyState === this.WS.OPEN) ws.send(JSON.stringify({ type: "ping" }));
|
|
166
|
+
} catch {
|
|
167
|
+
/* dropped */
|
|
168
|
+
}
|
|
169
|
+
}, HEARTBEAT_MS);
|
|
170
|
+
resolve();
|
|
171
|
+
};
|
|
172
|
+
ws.onmessage = (e: MessageEvent) =>
|
|
173
|
+
void this.onSignal(String(e.data)).catch((err) => this.log(`signal: ${err}`));
|
|
174
|
+
ws.onclose = () => {
|
|
175
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
176
|
+
if (!opened) reject(new Error("signaling closed before open"));
|
|
177
|
+
// Reconnect unless we were told to stop (mirrors the browser's resilience).
|
|
178
|
+
if (!this.closed) setTimeout(() => void this.connectSignaling().catch(() => {}), 1000);
|
|
179
|
+
};
|
|
180
|
+
ws.onerror = () => {
|
|
181
|
+
if (!opened) reject(new Error("signaling error before open"));
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async onSignal(raw: string): Promise<void> {
|
|
187
|
+
let msg: any;
|
|
188
|
+
try {
|
|
189
|
+
msg = JSON.parse(raw);
|
|
190
|
+
} catch {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
switch (msg.type) {
|
|
194
|
+
case "welcome": {
|
|
195
|
+
this.myId = String(msg.peer ?? "");
|
|
196
|
+
// Existing peers with a larger id: we are the offerer to them.
|
|
197
|
+
for (const other of (msg.peers as string[] | undefined) ?? []) {
|
|
198
|
+
if (this.myId < other) void this.beginOffer(other);
|
|
199
|
+
}
|
|
200
|
+
this.opts.onPeers?.(this.confirmedCount());
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
case "peer-join": {
|
|
204
|
+
const other = String(msg.peer ?? "");
|
|
205
|
+
if (other && this.myId && this.myId < other) void this.beginOffer(other);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
case "peer-leave":
|
|
209
|
+
this.dropConn(String(msg.peer ?? ""));
|
|
210
|
+
return;
|
|
211
|
+
case "offer":
|
|
212
|
+
return this.onOffer(String(msg.from ?? ""), msg.sdp, msg.iceServers);
|
|
213
|
+
case "answer":
|
|
214
|
+
return this.onAnswer(String(msg.from ?? ""), msg.sdp);
|
|
215
|
+
case "candidate":
|
|
216
|
+
return this.onCandidate(String(msg.from ?? ""), msg.candidate);
|
|
217
|
+
case "pong":
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private confirmedCount(): number {
|
|
223
|
+
let n = 0;
|
|
224
|
+
for (const c of this.conns.values()) if (c.confirmed) n++;
|
|
225
|
+
return n;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private newConn(peerId: string, offerer: boolean, pc: any): Conn {
|
|
229
|
+
const c: Conn = {
|
|
230
|
+
peerId,
|
|
231
|
+
offerer,
|
|
232
|
+
pc,
|
|
233
|
+
dc: undefined,
|
|
234
|
+
pendingCandidates: [],
|
|
235
|
+
send: { sendCtr: 0n },
|
|
236
|
+
recv: { lastSeen: -1n },
|
|
237
|
+
myNonce: randomHex(16),
|
|
238
|
+
confirmedIn: false,
|
|
239
|
+
confirmedOut: false,
|
|
240
|
+
confirmed: false,
|
|
241
|
+
confirmStarted: false,
|
|
242
|
+
sendChain: Promise.resolve(),
|
|
243
|
+
recvChain: Promise.resolve(),
|
|
244
|
+
};
|
|
245
|
+
this.conns.set(peerId, c);
|
|
246
|
+
pc.onicecandidate = (e: any) => {
|
|
247
|
+
if (e.candidate && this.ws?.readyState === this.WS.OPEN)
|
|
248
|
+
this.ws.send(JSON.stringify({ type: "candidate", to: peerId, candidate: e.candidate }));
|
|
249
|
+
};
|
|
250
|
+
pc.onconnectionstatechange = () => {
|
|
251
|
+
if (["failed", "closed", "disconnected"].includes(pc.connectionState)) this.dropConn(peerId);
|
|
252
|
+
};
|
|
253
|
+
return c;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private wireDataChannel(c: Conn, dc: any): void {
|
|
257
|
+
c.dc = dc;
|
|
258
|
+
dc.binaryType = "arraybuffer";
|
|
259
|
+
dc.onopen = async () => {
|
|
260
|
+
// keyEnc/keyDec are derived once both SDPs are exchanged (below); the open
|
|
261
|
+
// handler waits for them, then opens the confirmation handshake.
|
|
262
|
+
if (!c.keyEnc) return; // keys not ready yet — deriveKeys() re-invokes confirm
|
|
263
|
+
this.beginConfirm(c);
|
|
264
|
+
};
|
|
265
|
+
dc.onmessage = (e: any) => {
|
|
266
|
+
c.recvChain = c.recvChain.then(() => this.onFrame(c, e.data)).catch(() => {});
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private beginConfirm(c: Conn): void {
|
|
271
|
+
// Reachable from both dc.onopen and deriveKeys (whichever completes last) —
|
|
272
|
+
// open the handshake exactly once.
|
|
273
|
+
if (c.confirmStarted) return;
|
|
274
|
+
c.confirmStarted = true;
|
|
275
|
+
this.enqueueSeal(c, FLAG_CONFIRM, { t: "confirm", nonce: c.myNonce });
|
|
276
|
+
c.confirmTimer = setTimeout(() => {
|
|
277
|
+
if (!c.confirmed) this.dropConn(c.peerId);
|
|
278
|
+
}, CONFIRM_TIMEOUT_MS);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async beginOffer(peerId: string): Promise<void> {
|
|
282
|
+
if (this.conns.has(peerId) || this.closed) return;
|
|
283
|
+
try {
|
|
284
|
+
const iceServers = await this.ice();
|
|
285
|
+
const pc = new this.RTCPeerConnection({ iceServers });
|
|
286
|
+
const c = this.newConn(peerId, true, pc);
|
|
287
|
+
const dc = pc.createDataChannel(DC_LABEL);
|
|
288
|
+
this.wireDataChannel(c, dc);
|
|
289
|
+
const offer = await pc.createOffer();
|
|
290
|
+
await pc.setLocalDescription(offer);
|
|
291
|
+
c.localSdp = pc.localDescription.sdp;
|
|
292
|
+
if (this.conns.get(peerId) !== c || this.ws?.readyState !== this.WS.OPEN) return;
|
|
293
|
+
this.ws.send(JSON.stringify({ type: "offer", to: peerId, sdp: c.localSdp, iceServers }));
|
|
294
|
+
} catch (err) {
|
|
295
|
+
this.log(`beginOffer ${peerId}: ${err}`);
|
|
296
|
+
this.dropConn(peerId);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async onOffer(peerId: string, sdp: string, iceServers: any): Promise<void> {
|
|
301
|
+
if (!peerId || this.conns.has(peerId)) return;
|
|
302
|
+
try {
|
|
303
|
+
const pc = new this.RTCPeerConnection({ iceServers: iceServers ?? (await this.ice()) });
|
|
304
|
+
const c = this.newConn(peerId, false, pc);
|
|
305
|
+
pc.ondatachannel = (e: any) => this.wireDataChannel(c, e.channel);
|
|
306
|
+
c.remoteSdp = sdp;
|
|
307
|
+
await pc.setRemoteDescription({ type: "offer", sdp });
|
|
308
|
+
this.flushCandidates(c);
|
|
309
|
+
const answer = await pc.createAnswer();
|
|
310
|
+
await pc.setLocalDescription(answer);
|
|
311
|
+
c.localSdp = pc.localDescription.sdp;
|
|
312
|
+
await this.deriveKeys(c);
|
|
313
|
+
if (this.conns.get(peerId) !== c || this.ws?.readyState !== this.WS.OPEN) return;
|
|
314
|
+
this.ws.send(JSON.stringify({ type: "answer", to: peerId, sdp: c.localSdp }));
|
|
315
|
+
} catch (err) {
|
|
316
|
+
this.log(`onOffer ${peerId}: ${err}`);
|
|
317
|
+
this.dropConn(peerId);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private async onAnswer(peerId: string, sdp: string): Promise<void> {
|
|
322
|
+
const c = this.conns.get(peerId);
|
|
323
|
+
if (!c || !c.offerer) return;
|
|
324
|
+
try {
|
|
325
|
+
c.remoteSdp = sdp;
|
|
326
|
+
await c.pc.setRemoteDescription({ type: "answer", sdp });
|
|
327
|
+
this.flushCandidates(c);
|
|
328
|
+
await this.deriveKeys(c);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
this.log(`onAnswer ${peerId}: ${err}`);
|
|
331
|
+
this.dropConn(peerId);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private onCandidate(peerId: string, candidate: any): void {
|
|
336
|
+
const c = this.conns.get(peerId);
|
|
337
|
+
if (!c || !candidate) return;
|
|
338
|
+
// Buffer until the remote description is set, else addIceCandidate throws.
|
|
339
|
+
if (!c.remoteSdp) {
|
|
340
|
+
c.pendingCandidates.push(candidate);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
c.pc.addIceCandidate(candidate).catch(() => {});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private flushCandidates(c: Conn): void {
|
|
347
|
+
for (const cand of c.pendingCandidates.splice(0)) c.pc.addIceCandidate(cand).catch(() => {});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Derive the directional AES keys once both SDPs are known, then confirm. */
|
|
351
|
+
private async deriveKeys(c: Conn): Promise<void> {
|
|
352
|
+
if (c.keyEnc || !c.localSdp || !c.remoteSdp) return;
|
|
353
|
+
// Offerer: local=offer, remote=answer. Answerer: remote=offer, local=answer.
|
|
354
|
+
const [offerSdp, answerSdp] = c.offerer ? [c.localSdp, c.remoteSdp] : [c.remoteSdp, c.localSdp];
|
|
355
|
+
c.th = await computeTranscriptHash(offerSdp, answerSdp);
|
|
356
|
+
const { keyH2C, keyC2H } = await deriveDirKeys(this.opts.s, c.th);
|
|
357
|
+
// The offerer takes the host->client key to encrypt (client->host to decrypt);
|
|
358
|
+
// the answerer takes the mirror. Either way both directions are full-duplex.
|
|
359
|
+
c.keyEnc = c.offerer ? keyH2C : keyC2H;
|
|
360
|
+
c.keyDec = c.offerer ? keyC2H : keyH2C;
|
|
361
|
+
// If the DataChannel already opened before keys were ready, confirm now.
|
|
362
|
+
if (c.dc && c.dc.readyState === "open") this.beginConfirm(c);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private enqueueSeal(c: Conn, flags: number, obj: object): Promise<void> {
|
|
366
|
+
c.sendChain = c.sendChain.then(async () => {
|
|
367
|
+
if (!c.dc || c.dc.readyState !== "open" || !c.keyEnc || !c.th) return;
|
|
368
|
+
let frame: ArrayBuffer;
|
|
369
|
+
try {
|
|
370
|
+
frame = await e2eSeal(c.keyEnc, c.send, flags, c.th, packEnvelope(obj));
|
|
371
|
+
} catch {
|
|
372
|
+
this.dropConn(c.peerId); // counter overflow — fail closed
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
c.dc.send(frame);
|
|
377
|
+
} catch {
|
|
378
|
+
/* peer vanished mid-send */
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
return c.sendChain;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private async onFrame(c: Conn, data: any): Promise<void> {
|
|
385
|
+
if (!this.conns.has(c.peerId)) return;
|
|
386
|
+
if (typeof data === "string" || !c.keyDec || !c.th) return this.dropConn(c.peerId);
|
|
387
|
+
let env: any;
|
|
388
|
+
try {
|
|
389
|
+
const { plaintext } = await e2eOpen(c.keyDec, data, c.th, c.recv);
|
|
390
|
+
env = unpackEnvelope(plaintext);
|
|
391
|
+
} catch {
|
|
392
|
+
return this.dropConn(c.peerId); // bad tag/replay/AAD
|
|
393
|
+
}
|
|
394
|
+
if (!c.confirmed) {
|
|
395
|
+
if (!env || env.t !== "confirm") return this.dropConn(c.peerId);
|
|
396
|
+
if (typeof env.nonce === "string" && !c.confirmedOut) {
|
|
397
|
+
await this.enqueueSeal(c, FLAG_CONFIRM, {
|
|
398
|
+
t: "confirm",
|
|
399
|
+
nonce: c.myNonce,
|
|
400
|
+
echo: env.nonce,
|
|
401
|
+
});
|
|
402
|
+
c.confirmedOut = true;
|
|
403
|
+
}
|
|
404
|
+
if (env.echo && env.echo === c.myNonce) c.confirmedIn = true;
|
|
405
|
+
if (c.confirmedIn && c.confirmedOut) {
|
|
406
|
+
c.confirmed = true;
|
|
407
|
+
if (c.confirmTimer) clearTimeout(c.confirmTimer);
|
|
408
|
+
this.opts.onPeers?.(this.confirmedCount());
|
|
409
|
+
void this.startSync(c); // anti-entropy once the channel is trusted
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (!env || env.t === "confirm") return; // stray confirm — ignore
|
|
414
|
+
await this.onEnvelope(c, env);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Kick off anti-entropy: tell the peer what we hold; it replies with the diff. */
|
|
418
|
+
private async startSync(c: Conn): Promise<void> {
|
|
419
|
+
const ops = await this.opts.store.all();
|
|
420
|
+
this.enqueueSeal(c, 0, { t: "sync-req", have: haveVector(ops) });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async onEnvelope(c: Conn, env: any): Promise<void> {
|
|
424
|
+
if (env.t === "sync-req" && env.have && typeof env.have === "object") {
|
|
425
|
+
const missing = opsMissing(await this.opts.store.all(), env.have);
|
|
426
|
+
this.enqueueSeal(c, 0, { t: "sync-res", ops: missing });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (env.t === "sync-res" && Array.isArray(env.ops)) {
|
|
430
|
+
await this.ingest(env.ops.filter(isValidOp), c.peerId);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (env.t === "op" && isValidOp(env.op)) {
|
|
434
|
+
await this.ingest([env.op], c.peerId);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Merge inbound ops; surface + re-gossip only the ones new to our replica. */
|
|
440
|
+
private async ingest(ops: Op[], fromPeer: string): Promise<void> {
|
|
441
|
+
if (ops.length === 0) return;
|
|
442
|
+
const added = await this.opts.store.append(ops);
|
|
443
|
+
for (const op of added) this.opts.onOp?.(op);
|
|
444
|
+
// Gossip: forward genuinely-new ops to every OTHER confirmed peer. Dedup-by-id
|
|
445
|
+
// upstream makes this loop-free and terminating even on a partial mesh.
|
|
446
|
+
for (const op of added) this.broadcast({ t: "op", op }, fromPeer);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private broadcast(obj: { t: string; [k: string]: unknown }, exceptPeer?: string): void {
|
|
450
|
+
for (const c of this.conns.values()) {
|
|
451
|
+
if (!c.confirmed || c.peerId === exceptPeer) continue;
|
|
452
|
+
this.enqueueSeal(c, 0, obj);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private dropConn(peerId: string): void {
|
|
457
|
+
const c = this.conns.get(peerId);
|
|
458
|
+
if (!c) return;
|
|
459
|
+
if (c.confirmTimer) clearTimeout(c.confirmTimer);
|
|
460
|
+
try {
|
|
461
|
+
c.pc.close();
|
|
462
|
+
} catch {
|
|
463
|
+
/* already closed */
|
|
464
|
+
}
|
|
465
|
+
this.conns.delete(peerId);
|
|
466
|
+
this.opts.onPeers?.(this.confirmedCount());
|
|
467
|
+
}
|
|
468
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Browser replica backend: a channel's ops in LocalStorage (text-only chat fits
|
|
2
|
+
// comfortably). Same CRDT semantics as the Node jsonl backend (store.node.ts) —
|
|
3
|
+
// merge is a union by id, reads dedup + sort — so a browser tab and a CLI peer
|
|
4
|
+
// converge to identical threads. Synchronous (LocalStorage is), wrapped in the
|
|
5
|
+
// async ChannelPeerStore shape the peer expects.
|
|
6
|
+
|
|
7
|
+
import { isValidOp, type Op } from "./op.ts";
|
|
8
|
+
import { mergeOps, sortOps } from "./store.ts";
|
|
9
|
+
import type { ChannelPeerStore } from "./peer.ts";
|
|
10
|
+
|
|
11
|
+
const PREFIX = "ay29ch:";
|
|
12
|
+
|
|
13
|
+
export class LocalStorageStore implements ChannelPeerStore {
|
|
14
|
+
private key: string;
|
|
15
|
+
constructor(
|
|
16
|
+
channelId: string,
|
|
17
|
+
private storage: Storage = globalThis.localStorage,
|
|
18
|
+
) {
|
|
19
|
+
this.key = PREFIX + channelId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private read(): Op[] {
|
|
23
|
+
try {
|
|
24
|
+
const raw = this.storage.getItem(this.key);
|
|
25
|
+
if (!raw) return [];
|
|
26
|
+
const arr = JSON.parse(raw);
|
|
27
|
+
return sortOps((Array.isArray(arr) ? arr : []).filter(isValidOp));
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
all(): Promise<Op[]> {
|
|
34
|
+
return Promise.resolve(this.read());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
append(ops: Op[]): Promise<Op[]> {
|
|
38
|
+
const { merged, added } = mergeOps(this.read(), ops.filter(isValidOp));
|
|
39
|
+
if (added.length) this.storage.setItem(this.key, JSON.stringify(merged));
|
|
40
|
+
return Promise.resolve(added);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Node/Bun jsonl backend for a channel replica.
|
|
2
|
+
//
|
|
3
|
+
// One append-only file per channel, colocated with the project like the rest of
|
|
4
|
+
// agent-yes's per-cwd state (`<cwd>/.agent-yes/ch-<channelId>.jsonl` — the same
|
|
5
|
+
// convention as messageLog.ts's inbox/outbox). Writes follow messageLog's
|
|
6
|
+
// lock-free discipline: an O_APPEND of one line is atomic on POSIX, and reads
|
|
7
|
+
// dedup by op id, so a concurrent CLI + daemon appending the same op at worst
|
|
8
|
+
// writes a duplicate line that the next read/compaction collapses — never a lost
|
|
9
|
+
// or torn record. Best-effort, and never blocks a send.
|
|
10
|
+
|
|
11
|
+
import { appendFile, mkdir, readFile, writeFile } from "fs/promises";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { isValidOp, type Op } from "./op.ts";
|
|
14
|
+
import { mergeOps, sortOps } from "./store.ts";
|
|
15
|
+
|
|
16
|
+
/** Rewrite (dedup + sort) once the file grows past this many raw lines. */
|
|
17
|
+
const COMPACT_AT_LINES = 4000;
|
|
18
|
+
|
|
19
|
+
/** Path to a channel's jsonl replica under a project cwd. */
|
|
20
|
+
export function channelFilePath(cwd: string, channelId: string): string {
|
|
21
|
+
return path.join(cwd, ".agent-yes", `ch-${channelId}.jsonl`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read + parse a channel's ops, deduped and HLC-sorted. Missing file → []. */
|
|
25
|
+
export async function readOps(cwd: string, channelId: string): Promise<Op[]> {
|
|
26
|
+
let raw: string;
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(channelFilePath(cwd, channelId), "utf-8");
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const byId = new Map<string, Op>();
|
|
33
|
+
for (const line of raw.split("\n")) {
|
|
34
|
+
const t = line.trim();
|
|
35
|
+
if (!t) continue;
|
|
36
|
+
try {
|
|
37
|
+
const op = JSON.parse(t);
|
|
38
|
+
if (isValidOp(op) && !byId.has(op.id)) byId.set(op.id, op);
|
|
39
|
+
} catch {
|
|
40
|
+
/* skip corrupt/partial line */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return sortOps([...byId.values()]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Append `incoming` to a channel, deduping against what's already stored.
|
|
48
|
+
* Returns the ops that were genuinely new (so the daemon can rebroadcast just
|
|
49
|
+
* those). Opportunistically compacts when the file accumulates duplicate lines
|
|
50
|
+
* or grows large, keeping it bounded despite append-only writes.
|
|
51
|
+
*/
|
|
52
|
+
export async function appendOps(cwd: string, channelId: string, incoming: Op[]): Promise<Op[]> {
|
|
53
|
+
const valid = incoming.filter(isValidOp);
|
|
54
|
+
if (valid.length === 0) return [];
|
|
55
|
+
const file = channelFilePath(cwd, channelId);
|
|
56
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
57
|
+
|
|
58
|
+
const existing = await readOps(cwd, channelId);
|
|
59
|
+
const { added } = mergeOps(existing, valid);
|
|
60
|
+
if (added.length === 0) return [];
|
|
61
|
+
|
|
62
|
+
await appendFile(file, added.map((op) => JSON.stringify(op)).join("\n") + "\n");
|
|
63
|
+
|
|
64
|
+
// Compact if the on-disk line count now exceeds the deduped op count enough to
|
|
65
|
+
// matter (duplicates from concurrent writers) or crosses the size cap.
|
|
66
|
+
const rawLines = existing.length + valid.length; // upper bound on lines just written+read
|
|
67
|
+
if (rawLines > COMPACT_AT_LINES) {
|
|
68
|
+
const all = sortOps([...existing, ...added]);
|
|
69
|
+
await writeFile(file, all.map((op) => JSON.stringify(op)).join("\n") + "\n");
|
|
70
|
+
}
|
|
71
|
+
return added;
|
|
72
|
+
}
|