agent-relay 2.2.24 → 2.3.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 (116) hide show
  1. package/package.json +64 -21
  2. package/packages/acp-bridge/package.json +2 -2
  3. package/packages/api-types/package.json +1 -1
  4. package/packages/benchmark/package.json +5 -5
  5. package/packages/bridge/package.json +7 -7
  6. package/packages/cli-tester/package.json +1 -1
  7. package/packages/config/package.json +2 -2
  8. package/packages/continuity/package.json +2 -2
  9. package/packages/daemon/package.json +12 -12
  10. package/packages/hooks/package.json +4 -4
  11. package/packages/mcp/package.json +5 -5
  12. package/packages/memory/package.json +2 -2
  13. package/packages/policy/package.json +2 -2
  14. package/packages/protocol/package.json +1 -1
  15. package/packages/resiliency/package.json +1 -1
  16. package/packages/sdk/package.json +3 -3
  17. package/packages/sdk-ts/README.md +65 -0
  18. package/packages/sdk-ts/dist/__tests__/integration.test.d.ts +2 -0
  19. package/packages/sdk-ts/dist/__tests__/integration.test.d.ts.map +1 -0
  20. package/packages/sdk-ts/dist/__tests__/integration.test.js +139 -0
  21. package/packages/sdk-ts/dist/__tests__/integration.test.js.map +1 -0
  22. package/packages/sdk-ts/dist/__tests__/quickstart.test.d.ts +2 -0
  23. package/packages/sdk-ts/dist/__tests__/quickstart.test.d.ts.map +1 -0
  24. package/packages/sdk-ts/dist/__tests__/quickstart.test.js +176 -0
  25. package/packages/sdk-ts/dist/__tests__/quickstart.test.js.map +1 -0
  26. package/packages/sdk-ts/dist/browser.d.ts +16 -0
  27. package/packages/sdk-ts/dist/browser.d.ts.map +1 -0
  28. package/packages/sdk-ts/dist/browser.js +19 -0
  29. package/packages/sdk-ts/dist/browser.js.map +1 -0
  30. package/packages/sdk-ts/dist/client.d.ts +91 -0
  31. package/packages/sdk-ts/dist/client.d.ts.map +1 -0
  32. package/packages/sdk-ts/dist/client.js +360 -0
  33. package/packages/sdk-ts/dist/client.js.map +1 -0
  34. package/packages/sdk-ts/dist/consensus-helpers.d.ts +103 -0
  35. package/packages/sdk-ts/dist/consensus-helpers.d.ts.map +1 -0
  36. package/packages/sdk-ts/dist/consensus-helpers.js +147 -0
  37. package/packages/sdk-ts/dist/consensus-helpers.js.map +1 -0
  38. package/packages/sdk-ts/dist/consensus.d.ts +72 -0
  39. package/packages/sdk-ts/dist/consensus.d.ts.map +1 -0
  40. package/packages/sdk-ts/dist/consensus.js +378 -0
  41. package/packages/sdk-ts/dist/consensus.js.map +1 -0
  42. package/packages/sdk-ts/dist/examples/demo.d.ts +2 -0
  43. package/packages/sdk-ts/dist/examples/demo.d.ts.map +1 -0
  44. package/packages/sdk-ts/dist/examples/demo.js +63 -0
  45. package/packages/sdk-ts/dist/examples/demo.js.map +1 -0
  46. package/packages/sdk-ts/dist/examples/example.d.ts +2 -0
  47. package/packages/sdk-ts/dist/examples/example.d.ts.map +1 -0
  48. package/packages/sdk-ts/dist/examples/example.js +80 -0
  49. package/packages/sdk-ts/dist/examples/example.js.map +1 -0
  50. package/packages/sdk-ts/dist/examples/quickstart.d.ts +2 -0
  51. package/packages/sdk-ts/dist/examples/quickstart.d.ts.map +1 -0
  52. package/packages/sdk-ts/dist/examples/quickstart.js +56 -0
  53. package/packages/sdk-ts/dist/examples/quickstart.js.map +1 -0
  54. package/packages/sdk-ts/dist/examples/ralph-loop.d.ts +2 -0
  55. package/packages/sdk-ts/dist/examples/ralph-loop.d.ts.map +1 -0
  56. package/packages/sdk-ts/dist/examples/ralph-loop.js +281 -0
  57. package/packages/sdk-ts/dist/examples/ralph-loop.js.map +1 -0
  58. package/packages/sdk-ts/dist/index.d.ts +9 -0
  59. package/packages/sdk-ts/dist/index.d.ts.map +1 -0
  60. package/packages/sdk-ts/dist/index.js +9 -0
  61. package/packages/sdk-ts/dist/index.js.map +1 -0
  62. package/packages/sdk-ts/dist/logs.d.ts +47 -0
  63. package/packages/sdk-ts/dist/logs.d.ts.map +1 -0
  64. package/packages/sdk-ts/dist/logs.js +137 -0
  65. package/packages/sdk-ts/dist/logs.js.map +1 -0
  66. package/packages/sdk-ts/dist/protocol.d.ts +249 -0
  67. package/packages/sdk-ts/dist/protocol.d.ts.map +1 -0
  68. package/packages/sdk-ts/dist/protocol.js +2 -0
  69. package/packages/sdk-ts/dist/protocol.js.map +1 -0
  70. package/packages/sdk-ts/dist/pty.d.ts +8 -0
  71. package/packages/sdk-ts/dist/pty.d.ts.map +1 -0
  72. package/packages/sdk-ts/dist/pty.js +14 -0
  73. package/packages/sdk-ts/dist/pty.js.map +1 -0
  74. package/packages/sdk-ts/dist/relay.d.ts +118 -0
  75. package/packages/sdk-ts/dist/relay.d.ts.map +1 -0
  76. package/packages/sdk-ts/dist/relay.js +355 -0
  77. package/packages/sdk-ts/dist/relay.js.map +1 -0
  78. package/packages/sdk-ts/dist/relaycast.d.ts +57 -0
  79. package/packages/sdk-ts/dist/relaycast.d.ts.map +1 -0
  80. package/packages/sdk-ts/dist/relaycast.js +110 -0
  81. package/packages/sdk-ts/dist/relaycast.js.map +1 -0
  82. package/packages/sdk-ts/dist/shadow.d.ts +100 -0
  83. package/packages/sdk-ts/dist/shadow.d.ts.map +1 -0
  84. package/packages/sdk-ts/dist/shadow.js +174 -0
  85. package/packages/sdk-ts/dist/shadow.js.map +1 -0
  86. package/packages/sdk-ts/package.json +75 -0
  87. package/packages/sdk-ts/scripts/bundle-agent-relay.mjs +53 -0
  88. package/packages/sdk-ts/src/__tests__/integration.test.ts +170 -0
  89. package/packages/sdk-ts/src/__tests__/quickstart.test.ts +198 -0
  90. package/packages/sdk-ts/src/browser.ts +57 -0
  91. package/packages/sdk-ts/src/client.ts +491 -0
  92. package/packages/sdk-ts/src/consensus-helpers.ts +253 -0
  93. package/packages/sdk-ts/src/consensus.ts +506 -0
  94. package/packages/sdk-ts/src/examples/demo.ts +88 -0
  95. package/packages/sdk-ts/src/examples/example.ts +91 -0
  96. package/packages/sdk-ts/src/examples/quickstart.ts +72 -0
  97. package/packages/sdk-ts/src/examples/ralph-loop.ts +352 -0
  98. package/packages/sdk-ts/src/examples/sample-prd.json +37 -0
  99. package/packages/sdk-ts/src/index.ts +8 -0
  100. package/packages/sdk-ts/src/logs.ts +163 -0
  101. package/packages/sdk-ts/src/protocol.ts +266 -0
  102. package/packages/sdk-ts/src/pty.ts +16 -0
  103. package/packages/sdk-ts/src/relay.ts +454 -0
  104. package/packages/sdk-ts/src/relaycast.ts +143 -0
  105. package/packages/sdk-ts/src/shadow.ts +230 -0
  106. package/packages/sdk-ts/tsconfig.json +16 -0
  107. package/packages/spawner/package.json +1 -1
  108. package/packages/state/package.json +1 -1
  109. package/packages/storage/package.json +2 -2
  110. package/packages/telemetry/package.json +1 -1
  111. package/packages/trajectory/package.json +2 -2
  112. package/packages/user-directory/package.json +2 -2
  113. package/packages/utils/package.json +3 -3
  114. package/packages/wrapper/package.json +6 -6
  115. package/packages/mcp/SPEC.md +0 -1922
  116. package/packages/mcp/STAFFING_PLAN.md +0 -294
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Consensus / voting engine for distributed agent decision-making.
3
+ *
4
+ * Runs entirely in the SDK process — no broker protocol extension needed.
5
+ * Proposals are broadcast and votes collected via the relay messaging layer.
6
+ *
7
+ * Consensus types:
8
+ * - majority — simple >50%
9
+ * - supermajority — configurable threshold (default 2/3)
10
+ * - unanimous — all participants must approve
11
+ * - weighted — votes weighted by agent role/expertise
12
+ * - quorum — minimum participation + majority
13
+ */
14
+
15
+ import { randomBytes } from "node:crypto";
16
+ import { EventEmitter } from "node:events";
17
+
18
+ // Re-export all types and pure helpers so consumers can import everything
19
+ // from "consensus.js" without needing to know about the split.
20
+ export type {
21
+ ConsensusType,
22
+ VoteValue,
23
+ ProposalStatus,
24
+ AgentWeight,
25
+ Vote,
26
+ Proposal,
27
+ ConsensusResult,
28
+ ConsensusConfig,
29
+ ConsensusEvents,
30
+ ParsedProposalCommand,
31
+ } from "./consensus-helpers.js";
32
+
33
+ export {
34
+ formatProposalMessage,
35
+ formatResultMessage,
36
+ parseVoteCommand,
37
+ isConsensusCommand,
38
+ parseProposalCommand,
39
+ } from "./consensus-helpers.js";
40
+
41
+ import type {
42
+ ConsensusType,
43
+ VoteValue,
44
+ AgentWeight,
45
+ Vote,
46
+ Proposal,
47
+ ConsensusResult,
48
+ ConsensusConfig,
49
+ } from "./consensus-helpers.js";
50
+
51
+ // ── Defaults ─────────────────────────────────────────────────────────────────
52
+
53
+ const DEFAULT_CONFIG: ConsensusConfig = {
54
+ defaultTimeoutMs: 5 * 60 * 1000,
55
+ defaultConsensusType: "majority",
56
+ defaultThreshold: 0.67,
57
+ allowVoteChange: true,
58
+ autoResolve: true,
59
+ maxRetainedProposals: 100,
60
+ };
61
+
62
+ // ── Engine ───────────────────────────────────────────────────────────────────
63
+
64
+ export class ConsensusEngine extends EventEmitter {
65
+ private config: ConsensusConfig;
66
+ private proposals = new Map<string, Proposal>();
67
+ private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
68
+
69
+ constructor(config: Partial<ConsensusConfig> = {}) {
70
+ super();
71
+ this.config = { ...DEFAULT_CONFIG, ...config };
72
+ }
73
+
74
+ // ── Proposal management ──────────────────────────────────────────────────
75
+
76
+ createProposal(options: {
77
+ title: string;
78
+ description: string;
79
+ proposer: string;
80
+ participants: string[];
81
+ consensusType?: ConsensusType;
82
+ timeoutMs?: number;
83
+ quorum?: number;
84
+ threshold?: number;
85
+ weights?: AgentWeight[];
86
+ metadata?: Record<string, unknown>;
87
+ thread?: string;
88
+ }): Proposal {
89
+ const id = `prop_${Date.now()}_${randomBytes(4).toString("hex")}`;
90
+ const now = Date.now();
91
+ const timeoutMs = options.timeoutMs ?? this.config.defaultTimeoutMs;
92
+
93
+ const proposal: Proposal = {
94
+ id,
95
+ title: options.title,
96
+ description: options.description,
97
+ proposer: options.proposer,
98
+ consensusType:
99
+ options.consensusType ?? this.config.defaultConsensusType,
100
+ participants: options.participants,
101
+ quorum: options.quorum,
102
+ threshold: options.threshold ?? this.config.defaultThreshold,
103
+ weights: options.weights,
104
+ createdAt: now,
105
+ expiresAt: now + timeoutMs,
106
+ status: "pending",
107
+ votes: [],
108
+ metadata: options.metadata,
109
+ thread: options.thread ?? `consensus-${id}`,
110
+ };
111
+
112
+ this.proposals.set(id, proposal);
113
+ this.scheduleExpiry(proposal);
114
+ this.emit("proposal:created", proposal);
115
+ return proposal;
116
+ }
117
+
118
+ vote(
119
+ proposalId: string,
120
+ agent: string,
121
+ value: VoteValue,
122
+ reason?: string,
123
+ ): { success: boolean; error?: string; proposal?: Proposal } {
124
+ const proposal = this.proposals.get(proposalId);
125
+
126
+ if (!proposal) return { success: false, error: "Proposal not found" };
127
+ if (proposal.status !== "pending")
128
+ return { success: false, error: `Proposal is ${proposal.status}` };
129
+ if (!proposal.participants.includes(agent))
130
+ return { success: false, error: "Agent not a participant" };
131
+ if (Date.now() > proposal.expiresAt) {
132
+ this.expireProposal(proposal);
133
+ return { success: false, error: "Proposal has expired" };
134
+ }
135
+
136
+ const existingIdx = proposal.votes.findIndex((v) => v.agent === agent);
137
+ if (existingIdx >= 0) {
138
+ if (!this.config.allowVoteChange)
139
+ return {
140
+ success: false,
141
+ error: "Vote already cast and changes not allowed",
142
+ };
143
+ proposal.votes.splice(existingIdx, 1);
144
+ }
145
+
146
+ const weight = this.getAgentWeight(proposal, agent);
147
+ const vote: Vote = { agent, value, weight, reason, timestamp: Date.now() };
148
+
149
+ proposal.votes.push(vote);
150
+ this.emit("proposal:voted", proposal, vote);
151
+
152
+ if (this.config.autoResolve) {
153
+ const result = this.calculateResult(proposal);
154
+ if (this.canResolveEarly(proposal, result)) {
155
+ this.resolveProposal(proposal, result);
156
+ }
157
+ }
158
+
159
+ return { success: true, proposal };
160
+ }
161
+
162
+ getProposal(proposalId: string): Proposal | null {
163
+ return this.proposals.get(proposalId) ?? null;
164
+ }
165
+
166
+ getProposalsForAgent(agent: string): Proposal[] {
167
+ const out: Proposal[] = [];
168
+ for (const p of this.proposals.values()) {
169
+ if (p.proposer === agent || p.participants.includes(agent)) out.push(p);
170
+ }
171
+ return out;
172
+ }
173
+
174
+ getPendingVotesForAgent(agent: string): Proposal[] {
175
+ const out: Proposal[] = [];
176
+ for (const p of this.proposals.values()) {
177
+ if (p.status !== "pending") continue;
178
+ if (!p.participants.includes(agent)) continue;
179
+ if (p.votes.some((v) => v.agent === agent)) continue;
180
+ out.push(p);
181
+ }
182
+ return out;
183
+ }
184
+
185
+ cancelProposal(
186
+ proposalId: string,
187
+ agent: string,
188
+ ): { success: boolean; error?: string } {
189
+ const proposal = this.proposals.get(proposalId);
190
+ if (!proposal) return { success: false, error: "Proposal not found" };
191
+ if (proposal.proposer !== agent)
192
+ return { success: false, error: "Only proposer can cancel" };
193
+ if (proposal.status !== "pending")
194
+ return { success: false, error: `Proposal is ${proposal.status}` };
195
+
196
+ proposal.status = "cancelled";
197
+ this.clearExpiryTimer(proposalId);
198
+ this.emit("proposal:cancelled", proposal);
199
+ this.evictOldProposals();
200
+ return { success: true };
201
+ }
202
+
203
+ forceResolve(proposalId: string): ConsensusResult | null {
204
+ const proposal = this.proposals.get(proposalId);
205
+ if (!proposal || proposal.status !== "pending") return null;
206
+ const result = this.calculateResult(proposal);
207
+ this.resolveProposal(proposal, result);
208
+ return result;
209
+ }
210
+
211
+ // ── Calculation ──────────────────────────────────────────────────────────
212
+
213
+ calculateResult(proposal: Proposal): ConsensusResult {
214
+ let approveWeight = 0;
215
+ let rejectWeight = 0;
216
+ let abstainWeight = 0;
217
+
218
+ for (const v of proposal.votes) {
219
+ if (v.value === "approve") approveWeight += v.weight;
220
+ else if (v.value === "reject") rejectWeight += v.weight;
221
+ else abstainWeight += v.weight;
222
+ }
223
+
224
+ const totalWeight = this.getTotalWeight(proposal);
225
+ const votedWeight = approveWeight + rejectWeight + abstainWeight;
226
+ const participation = totalWeight > 0 ? votedWeight / totalWeight : 0;
227
+
228
+ const voters = new Set(proposal.votes.map((v) => v.agent));
229
+ const nonVoters = proposal.participants.filter((p) => !voters.has(p));
230
+
231
+ const quorumRequired =
232
+ proposal.quorum ?? Math.ceil(proposal.participants.length / 2);
233
+ const quorumMet = proposal.votes.length >= quorumRequired;
234
+
235
+ const decision = this.determineDecision(proposal, {
236
+ approveWeight,
237
+ rejectWeight,
238
+ votedWeight,
239
+ quorumMet,
240
+ });
241
+
242
+ return {
243
+ decision,
244
+ approveWeight,
245
+ rejectWeight,
246
+ abstainWeight,
247
+ participation,
248
+ quorumMet,
249
+ resolvedAt: Date.now(),
250
+ nonVoters,
251
+ };
252
+ }
253
+
254
+ private determineDecision(
255
+ proposal: Proposal,
256
+ counts: {
257
+ approveWeight: number;
258
+ rejectWeight: number;
259
+ votedWeight: number;
260
+ quorumMet: boolean;
261
+ },
262
+ ): "approved" | "rejected" | "no_consensus" {
263
+ const { approveWeight, rejectWeight, votedWeight, quorumMet } = counts;
264
+
265
+ switch (proposal.consensusType) {
266
+ case "unanimous": {
267
+ if (proposal.votes.some((v) => v.value === "reject")) return "rejected";
268
+ if (proposal.votes.length < proposal.participants.length)
269
+ return "no_consensus";
270
+ return proposal.votes.every((v) => v.value === "approve")
271
+ ? "approved"
272
+ : "rejected";
273
+ }
274
+
275
+ case "supermajority": {
276
+ const threshold = proposal.threshold ?? this.config.defaultThreshold;
277
+ if (votedWeight === 0) return "no_consensus";
278
+ if (approveWeight / votedWeight >= threshold) return "approved";
279
+ if (rejectWeight / votedWeight > 1 - threshold) return "rejected";
280
+ return "no_consensus";
281
+ }
282
+
283
+ case "quorum": {
284
+ if (!quorumMet) return "no_consensus";
285
+ // fall through to majority
286
+ }
287
+ // eslint-disable-next-line no-fallthrough
288
+ case "majority": {
289
+ if (votedWeight === 0) return "no_consensus";
290
+ if (approveWeight > rejectWeight) return "approved";
291
+ if (rejectWeight > approveWeight) return "rejected";
292
+ return "no_consensus";
293
+ }
294
+
295
+ case "weighted": {
296
+ if (votedWeight === 0) return "no_consensus";
297
+ if (approveWeight > rejectWeight) return "approved";
298
+ if (rejectWeight > approveWeight) return "rejected";
299
+ return "no_consensus";
300
+ }
301
+
302
+ default:
303
+ return "no_consensus";
304
+ }
305
+ }
306
+
307
+ private canResolveEarly(
308
+ proposal: Proposal,
309
+ result: ConsensusResult,
310
+ ): boolean {
311
+ const totalWeight = this.getTotalWeight(proposal);
312
+ const remainingWeight =
313
+ totalWeight -
314
+ (result.approveWeight + result.rejectWeight + result.abstainWeight);
315
+
316
+ switch (proposal.consensusType) {
317
+ case "unanimous":
318
+ return (
319
+ proposal.votes.some((v) => v.value === "reject") ||
320
+ proposal.votes.length === proposal.participants.length
321
+ );
322
+
323
+ case "supermajority": {
324
+ const threshold = proposal.threshold ?? this.config.defaultThreshold;
325
+ const votedWeight =
326
+ result.approveWeight + result.rejectWeight + result.abstainWeight;
327
+ if (
328
+ votedWeight > 0 &&
329
+ result.approveWeight / votedWeight >= threshold
330
+ ) {
331
+ return (
332
+ result.approveWeight / (votedWeight + remainingWeight) >= threshold
333
+ );
334
+ }
335
+ if (
336
+ votedWeight > 0 &&
337
+ result.rejectWeight / votedWeight > 1 - threshold
338
+ ) {
339
+ return (
340
+ result.rejectWeight / (votedWeight + remainingWeight) > 1 - threshold
341
+ );
342
+ }
343
+ return false;
344
+ }
345
+
346
+ case "majority":
347
+ case "weighted":
348
+ return (
349
+ result.approveWeight > totalWeight / 2 ||
350
+ result.rejectWeight > totalWeight / 2
351
+ );
352
+
353
+ case "quorum":
354
+ if (!result.quorumMet) return false;
355
+ return (
356
+ result.approveWeight > totalWeight / 2 ||
357
+ result.rejectWeight > totalWeight / 2
358
+ );
359
+
360
+ default:
361
+ return false;
362
+ }
363
+ }
364
+
365
+ // ── Weight helpers ───────────────────────────────────────────────────────
366
+
367
+ private getAgentWeight(proposal: Proposal, agent: string): number {
368
+ if (proposal.weights) {
369
+ const w = proposal.weights.find((w) => w.agent === agent);
370
+ if (w) return w.weight;
371
+ }
372
+ return 1;
373
+ }
374
+
375
+ private getTotalWeight(proposal: Proposal): number {
376
+ let total = 0;
377
+ for (const p of proposal.participants) {
378
+ total += this.getAgentWeight(proposal, p);
379
+ }
380
+ return total;
381
+ }
382
+
383
+ // ── Lifecycle ────────────────────────────────────────────────────────────
384
+
385
+ private resolveProposal(
386
+ proposal: Proposal,
387
+ result: ConsensusResult,
388
+ ): void {
389
+ proposal.status =
390
+ result.decision === "approved"
391
+ ? "approved"
392
+ : result.decision === "rejected"
393
+ ? "rejected"
394
+ : "rejected"; // no_consensus maps to rejected (not expired — that's for timeouts)
395
+ proposal.result = result;
396
+ this.clearExpiryTimer(proposal.id);
397
+ this.emit("proposal:resolved", proposal, result);
398
+ this.evictOldProposals();
399
+ }
400
+
401
+ private expireProposal(proposal: Proposal): void {
402
+ if (proposal.status !== "pending") return;
403
+ const result = this.calculateResult(proposal);
404
+ proposal.status = "expired";
405
+ proposal.result = result;
406
+ this.clearExpiryTimer(proposal.id);
407
+ this.emit("proposal:expired", proposal);
408
+ this.evictOldProposals();
409
+ }
410
+
411
+ private scheduleExpiry(proposal: Proposal): void {
412
+ const timeoutMs = proposal.expiresAt - Date.now();
413
+ if (timeoutMs <= 0) {
414
+ this.expireProposal(proposal);
415
+ return;
416
+ }
417
+ const timer = setTimeout(() => this.expireProposal(proposal), timeoutMs);
418
+ timer.unref();
419
+ this.expiryTimers.set(proposal.id, timer);
420
+ }
421
+
422
+ private clearExpiryTimer(proposalId: string): void {
423
+ const timer = this.expiryTimers.get(proposalId);
424
+ if (timer) {
425
+ clearTimeout(timer);
426
+ this.expiryTimers.delete(proposalId);
427
+ }
428
+ }
429
+
430
+ /** Remove oldest resolved/expired/cancelled proposals when over the limit. */
431
+ private evictOldProposals(): void {
432
+ const max = this.config.maxRetainedProposals;
433
+ if (max <= 0) return; // unlimited
434
+
435
+ const terminal: Proposal[] = [];
436
+ for (const p of this.proposals.values()) {
437
+ if (p.status !== "pending") terminal.push(p);
438
+ }
439
+
440
+ if (terminal.length <= max) return;
441
+
442
+ // Sort oldest-first by createdAt, evict the excess
443
+ terminal.sort((a, b) => a.createdAt - b.createdAt);
444
+ const toEvict = terminal.length - max;
445
+ for (let i = 0; i < toEvict; i++) {
446
+ this.proposals.delete(terminal[i].id);
447
+ }
448
+ }
449
+
450
+ cleanup(): void {
451
+ for (const timer of this.expiryTimers.values()) clearTimeout(timer);
452
+ this.expiryTimers.clear();
453
+ }
454
+
455
+ // ── Stats ────────────────────────────────────────────────────────────────
456
+
457
+ getStats(): {
458
+ total: number;
459
+ pending: number;
460
+ approved: number;
461
+ rejected: number;
462
+ expired: number;
463
+ cancelled: number;
464
+ avgParticipation: number;
465
+ } {
466
+ let pending = 0,
467
+ approved = 0,
468
+ rejected = 0,
469
+ expired = 0,
470
+ cancelled = 0;
471
+ let totalParticipation = 0;
472
+ let resolvedCount = 0;
473
+
474
+ for (const p of this.proposals.values()) {
475
+ if (p.status === "pending") pending++;
476
+ else if (p.status === "approved") approved++;
477
+ else if (p.status === "rejected") rejected++;
478
+ else if (p.status === "expired") expired++;
479
+ else if (p.status === "cancelled") cancelled++;
480
+
481
+ if (p.result) {
482
+ totalParticipation += p.result.participation;
483
+ resolvedCount++;
484
+ }
485
+ }
486
+
487
+ return {
488
+ total: this.proposals.size,
489
+ pending,
490
+ approved,
491
+ rejected,
492
+ expired,
493
+ cancelled,
494
+ avgParticipation:
495
+ resolvedCount > 0 ? totalParticipation / resolvedCount : 0,
496
+ };
497
+ }
498
+ }
499
+
500
+ // ── Factory ──────────────────────────────────────────────────────────────────
501
+
502
+ export function createConsensusEngine(
503
+ config?: Partial<ConsensusConfig>,
504
+ ): ConsensusEngine {
505
+ return new ConsensusEngine(config);
506
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Runnable demo — shows the full AgentRelay message flow with real output.
3
+ * Uses `cat` as a universally-available stand-in for agent CLIs.
4
+ *
5
+ * Run:
6
+ * npm run build && npm run demo
7
+ */
8
+ import { AgentRelay } from "../relay.js";
9
+
10
+ const relay = new AgentRelay({ env: process.env });
11
+
12
+ // ── Event hooks ─────────────────────────────────────────────────────────────
13
+
14
+ relay.onMessageReceived = (message) => {
15
+ console.log(` 📨 received │ from=${message.from} to=${message.to} text="${message.text}"`);
16
+ };
17
+
18
+ relay.onMessageSent = (message) => {
19
+ console.log(` 📤 sent │ from=${message.from} to=${message.to} text="${message.text}"`);
20
+ };
21
+
22
+ relay.onAgentSpawned = (agent) => {
23
+ console.log(` 🟢 spawned │ ${agent.name} (${agent.runtime})`);
24
+ };
25
+
26
+ relay.onAgentReleased = (agent) => {
27
+ console.log(` 🔴 released │ ${agent.name}`);
28
+ };
29
+
30
+ relay.onAgentExited = (agent) => {
31
+ console.log(` ⚪ exited │ ${agent.name}`);
32
+ };
33
+
34
+ // ── Spawn agents ────────────────────────────────────────────────────────────
35
+
36
+ console.log("\n─── Spawning agents ───\n");
37
+
38
+ const [agentA, agentB] = await Promise.all([
39
+ relay.spawnPty({ name: "AgentA", cli: "claude", args: ["--print"], channels: ["general"] }),
40
+ relay.spawnPty({ name: "AgentB", cli: "claude", args: ["--print"], channels: ["general"] }),
41
+ ]);
42
+
43
+ // ── Send messages ───────────────────────────────────────────────────────────
44
+
45
+ console.log("\n─── Sending messages ───\n");
46
+
47
+ const human = relay.human({ name: "System" });
48
+ await human.sendMessage({ to: agentA.name, text: "Hello AgentA, welcome!" });
49
+ await human.sendMessage({ to: agentB.name, text: "Hello AgentB, welcome!" });
50
+
51
+ // Agent-to-agent messaging
52
+ await agentA.sendMessage({ to: agentB.name, text: "Hey B, got a task for you" });
53
+ await agentB.sendMessage({ to: agentA.name, text: "On it!" });
54
+
55
+ // Threaded conversation
56
+ const thread = await human.sendMessage({ to: agentA.name, text: "Status update?" });
57
+ await agentA.sendMessage({ to: human.name, text: "All good!", threadId: thread.eventId });
58
+
59
+ // Priority messages
60
+ await human.sendMessage({ to: agentA.name, text: "Critical alert!", priority: 0 });
61
+ await human.sendMessage({ to: agentB.name, text: "Low priority FYI", priority: 4 });
62
+
63
+ // Small delay to let events propagate
64
+ await new Promise((r) => setTimeout(r, 500));
65
+
66
+ // ── List agents ─────────────────────────────────────────────────────────────
67
+
68
+ console.log("\n─── Active agents ───\n");
69
+
70
+ const agents = await relay.listAgents();
71
+ for (const agent of agents) {
72
+ console.log(` • ${agent.name} runtime=${agent.runtime} channels=[${agent.channels}]`);
73
+ }
74
+
75
+ // ── Release all ─────────────────────────────────────────────────────────────
76
+
77
+ console.log("\n─── Releasing agents ───\n");
78
+
79
+ for (const agent of agents) {
80
+ await agent.release();
81
+ }
82
+
83
+ await new Promise((r) => setTimeout(r, 300));
84
+
85
+ // ── Shutdown ────────────────────────────────────────────────────────────────
86
+
87
+ await relay.shutdown();
88
+ console.log("\n─── Done ───\n");
@@ -0,0 +1,91 @@
1
+ import { AgentRelayClient } from "../client.js";
2
+
3
+ function parseArgs(raw: string | undefined): string[] {
4
+ if (!raw || raw.trim() === "") {
5
+ return [];
6
+ }
7
+ return raw
8
+ .split(" ")
9
+ .map((part) => part.trim())
10
+ .filter((part) => part.length > 0);
11
+ }
12
+
13
+ function now(): string {
14
+ return new Date().toISOString();
15
+ }
16
+
17
+ async function main(): Promise<void> {
18
+ const codexCmd = process.env.CODEX_CMD ?? "codex";
19
+ const codexArgs = parseArgs(process.env.CODEX_ARGS);
20
+ const channel = process.env.RELAY_CHANNEL ?? "general";
21
+ const xName = process.env.AGENT_X_NAME ?? "CodexX";
22
+ const oName = process.env.AGENT_O_NAME ?? "CodexO";
23
+
24
+ const client = await AgentRelayClient.start({
25
+ channels: [channel],
26
+ });
27
+
28
+ const stopLogging = client.onEvent((event) => {
29
+ console.log(`[${now()}] event`, JSON.stringify(event));
30
+ });
31
+ const stopStderrLogging = client.onBrokerStderr((line) => {
32
+ console.log(`[${now()}] broker:stderr ${line}`);
33
+ });
34
+
35
+ const cleanup = async () => {
36
+ console.log(`[${now()}] cleaning up agents + broker`);
37
+ try {
38
+ await client.release(xName);
39
+ } catch {
40
+ // ignore
41
+ }
42
+ try {
43
+ await client.release(oName);
44
+ } catch {
45
+ // ignore
46
+ }
47
+ await client.shutdown();
48
+ stopLogging();
49
+ stopStderrLogging();
50
+ };
51
+
52
+ process.on("SIGINT", async () => {
53
+ console.log(`[${now()}] SIGINT received`);
54
+ await cleanup();
55
+ process.exit(0);
56
+ });
57
+
58
+ process.on("SIGTERM", async () => {
59
+ console.log(`[${now()}] SIGTERM received`);
60
+ await cleanup();
61
+ process.exit(0);
62
+ });
63
+
64
+ console.log(`[${now()}] spawning ${xName} (${codexCmd} ${codexArgs.join(" ")})`);
65
+ await client.spawnPty({
66
+ name: xName,
67
+ cli: codexCmd,
68
+ args: codexArgs,
69
+ channels: [channel],
70
+ });
71
+
72
+ console.log(`[${now()}] spawning ${oName} (${codexCmd} ${codexArgs.join(" ")})`);
73
+ await client.spawnPty({
74
+ name: oName,
75
+ cli: codexCmd,
76
+ args: codexArgs,
77
+ channels: [channel],
78
+ });
79
+
80
+ console.log(
81
+ `[${now()}] workers spawned. send kickoff via Relaycast (MCP relay_send) and watch events here (Ctrl+C to stop).`,
82
+ );
83
+ await new Promise<void>(() => {
84
+ // keep process alive while events stream
85
+ });
86
+ }
87
+
88
+ main().catch((error) => {
89
+ console.error(`[${now()}] fatal`, error);
90
+ process.exit(1);
91
+ });