@tjamescouch/agentchat 0.21.1 → 0.22.1

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.
@@ -0,0 +1,612 @@
1
+ /**
2
+ * AgentChat Proposals Module
3
+ * Handles structured negotiation between agents
4
+ *
5
+ * Proposals enable agents to make verifiable, signed commitments
6
+ * for work, services, or payments.
7
+ */
8
+
9
+ import { generateProposalId, ProposalStatus } from './protocol.js';
10
+ import type { ProposalStatus as ProposalStatusType } from './types.js';
11
+
12
+ // ============ Types ============
13
+
14
+ export interface ProposalInput {
15
+ id?: string;
16
+ from: string;
17
+ to: string;
18
+ task: string;
19
+ amount?: number | null;
20
+ currency?: string | null;
21
+ payment_code?: string | null;
22
+ terms?: string | null;
23
+ expires?: number | null;
24
+ sig: string;
25
+ elo_stake?: number | null;
26
+ }
27
+
28
+ export interface StoredProposal {
29
+ id: string;
30
+ from: string;
31
+ to: string;
32
+ task: string;
33
+ amount: number | null;
34
+ currency: string | null;
35
+ payment_code: string | null;
36
+ terms: string | null;
37
+ expires: number | null;
38
+ status: string;
39
+ created_at: number;
40
+ updated_at: number;
41
+ sig: string;
42
+ proposer_stake: number | null;
43
+ acceptor_stake: number | null;
44
+ stakes_escrowed: boolean;
45
+ response_sig: string | null;
46
+ response_payment_code: string | null;
47
+ completed_at: number | null;
48
+ completion_proof: string | null;
49
+ dispute_reason: string | null;
50
+ reject_reason?: string | null;
51
+ completion_sig?: string;
52
+ completed_by?: string;
53
+ dispute_sig?: string;
54
+ disputed_by?: string;
55
+ disputed_at?: number;
56
+ }
57
+
58
+ export interface ProposalResult {
59
+ proposal?: StoredProposal;
60
+ error?: string;
61
+ status?: string;
62
+ }
63
+
64
+ export interface ListOptions {
65
+ status?: string;
66
+ role?: 'from' | 'to';
67
+ limit?: number;
68
+ }
69
+
70
+ export interface ProposalStats {
71
+ total: number;
72
+ byStatus: Record<string, number>;
73
+ agents: number;
74
+ }
75
+
76
+ export interface FormattedProposal {
77
+ id: string;
78
+ from: string;
79
+ to: string;
80
+ task: string;
81
+ amount: number | null;
82
+ currency: string | null;
83
+ payment_code: string | null;
84
+ terms: string | null;
85
+ expires: number | null;
86
+ status: string;
87
+ created_at: number;
88
+ sig: string;
89
+ elo_stake: number | null;
90
+ }
91
+
92
+ export interface ProposalResponseBase {
93
+ proposal_id: string;
94
+ status: string;
95
+ updated_at: number;
96
+ }
97
+
98
+ export interface AcceptResponse extends ProposalResponseBase {
99
+ from: string;
100
+ to: string;
101
+ payment_code: string | null;
102
+ sig: string | null;
103
+ proposer_stake: number | null;
104
+ acceptor_stake: number | null;
105
+ }
106
+
107
+ export interface RejectResponse extends ProposalResponseBase {
108
+ from: string;
109
+ to: string;
110
+ reason: string | null | undefined;
111
+ sig: string | null;
112
+ }
113
+
114
+ export interface CompleteResponse extends ProposalResponseBase {
115
+ from: string;
116
+ to: string;
117
+ completed_by: string | undefined;
118
+ completed_at: number | null;
119
+ proof: string | null;
120
+ sig: string | undefined;
121
+ elo_stakes: {
122
+ proposer: number;
123
+ acceptor: number;
124
+ };
125
+ }
126
+
127
+ export interface DisputeResponse extends ProposalResponseBase {
128
+ from: string;
129
+ to: string;
130
+ disputed_by: string | undefined;
131
+ disputed_at: number | undefined;
132
+ reason: string | null;
133
+ sig: string | undefined;
134
+ elo_stakes: {
135
+ proposer: number;
136
+ acceptor: number;
137
+ };
138
+ }
139
+
140
+ export type ProposalResponse = AcceptResponse | RejectResponse | CompleteResponse | DisputeResponse | ProposalResponseBase;
141
+
142
+ // ============ ProposalStore Class ============
143
+
144
+ /**
145
+ * In-memory proposal store
146
+ * In production, this could be backed by persistence
147
+ */
148
+ export class ProposalStore {
149
+ private proposals: Map<string, StoredProposal>;
150
+ private byAgent: Map<string, Set<string>>;
151
+ private cleanupInterval: ReturnType<typeof setInterval> | null;
152
+
153
+ constructor() {
154
+ // Map of proposal_id -> proposal object
155
+ this.proposals = new Map();
156
+
157
+ // Index by agent for quick lookups
158
+ this.byAgent = new Map(); // agent_id -> Set of proposal_ids
159
+
160
+ // Cleanup expired proposals periodically
161
+ this.cleanupInterval = setInterval(() => this.cleanupExpired(), 60000);
162
+ }
163
+
164
+ /**
165
+ * Create a new proposal
166
+ */
167
+ create(proposal: ProposalInput): StoredProposal {
168
+ const id = proposal.id || generateProposalId();
169
+ const now = Date.now();
170
+
171
+ const stored: StoredProposal = {
172
+ id,
173
+ from: proposal.from,
174
+ to: proposal.to,
175
+ task: proposal.task,
176
+ amount: proposal.amount || null,
177
+ currency: proposal.currency || null,
178
+ payment_code: proposal.payment_code || null,
179
+ terms: proposal.terms || null,
180
+ expires: proposal.expires ? now + (proposal.expires * 1000) : null,
181
+ status: ProposalStatus.PENDING,
182
+ created_at: now,
183
+ updated_at: now,
184
+ sig: proposal.sig,
185
+ // ELO staking
186
+ proposer_stake: proposal.elo_stake || null,
187
+ acceptor_stake: null,
188
+ stakes_escrowed: false,
189
+ // Response tracking
190
+ response_sig: null,
191
+ response_payment_code: null,
192
+ completed_at: null,
193
+ completion_proof: null,
194
+ dispute_reason: null
195
+ };
196
+
197
+ this.proposals.set(id, stored);
198
+
199
+ // Index by both agents
200
+ this._indexAgent(proposal.from, id);
201
+ this._indexAgent(proposal.to, id);
202
+
203
+ return stored;
204
+ }
205
+
206
+ /**
207
+ * Get a proposal by ID
208
+ */
209
+ get(id: string): StoredProposal | null {
210
+ const proposal = this.proposals.get(id);
211
+ if (!proposal) return null;
212
+
213
+ // Check expiration
214
+ if (proposal.expires && Date.now() > proposal.expires) {
215
+ if (proposal.status === ProposalStatus.PENDING) {
216
+ proposal.status = ProposalStatus.EXPIRED;
217
+ proposal.updated_at = Date.now();
218
+ }
219
+ }
220
+
221
+ return proposal;
222
+ }
223
+
224
+ /**
225
+ * Accept a proposal
226
+ * @param id - Proposal ID
227
+ * @param acceptorId - Agent accepting the proposal
228
+ * @param sig - Signature of acceptance
229
+ * @param payment_code - Optional payment code
230
+ * @param acceptor_stake - Optional ELO stake from acceptor
231
+ */
232
+ accept(
233
+ id: string,
234
+ acceptorId: string,
235
+ sig: string,
236
+ payment_code: string | null = null,
237
+ acceptor_stake: number | null = null
238
+ ): ProposalResult {
239
+ const proposal = this.get(id);
240
+ if (!proposal) {
241
+ return { error: 'PROPOSAL_NOT_FOUND' };
242
+ }
243
+
244
+ if (proposal.status !== ProposalStatus.PENDING) {
245
+ return { error: 'PROPOSAL_NOT_PENDING', status: proposal.status };
246
+ }
247
+
248
+ if (proposal.to !== acceptorId) {
249
+ return { error: 'NOT_PROPOSAL_RECIPIENT' };
250
+ }
251
+
252
+ if (proposal.expires && Date.now() > proposal.expires) {
253
+ proposal.status = ProposalStatus.EXPIRED;
254
+ proposal.updated_at = Date.now();
255
+ return { error: 'PROPOSAL_EXPIRED' };
256
+ }
257
+
258
+ proposal.status = ProposalStatus.ACCEPTED;
259
+ proposal.response_sig = sig;
260
+ proposal.response_payment_code = payment_code;
261
+ proposal.acceptor_stake = acceptor_stake;
262
+ proposal.updated_at = Date.now();
263
+
264
+ return { proposal };
265
+ }
266
+
267
+ /**
268
+ * Reject a proposal
269
+ */
270
+ reject(
271
+ id: string,
272
+ rejectorId: string,
273
+ sig: string,
274
+ reason: string | null = null
275
+ ): ProposalResult {
276
+ const proposal = this.get(id);
277
+ if (!proposal) {
278
+ return { error: 'PROPOSAL_NOT_FOUND' };
279
+ }
280
+
281
+ if (proposal.status !== ProposalStatus.PENDING) {
282
+ return { error: 'PROPOSAL_NOT_PENDING', status: proposal.status };
283
+ }
284
+
285
+ if (proposal.to !== rejectorId) {
286
+ return { error: 'NOT_PROPOSAL_RECIPIENT' };
287
+ }
288
+
289
+ proposal.status = ProposalStatus.REJECTED;
290
+ proposal.response_sig = sig;
291
+ proposal.reject_reason = reason;
292
+ proposal.updated_at = Date.now();
293
+
294
+ return { proposal };
295
+ }
296
+
297
+ /**
298
+ * Mark a proposal as complete
299
+ */
300
+ complete(
301
+ id: string,
302
+ completerId: string,
303
+ sig: string,
304
+ proof: string | null = null
305
+ ): ProposalResult {
306
+ const proposal = this.get(id);
307
+ if (!proposal) {
308
+ return { error: 'PROPOSAL_NOT_FOUND' };
309
+ }
310
+
311
+ if (proposal.status !== ProposalStatus.ACCEPTED) {
312
+ return { error: 'PROPOSAL_NOT_ACCEPTED', status: proposal.status };
313
+ }
314
+
315
+ // Either party can mark as complete
316
+ if (proposal.from !== completerId && proposal.to !== completerId) {
317
+ return { error: 'NOT_PROPOSAL_PARTY' };
318
+ }
319
+
320
+ proposal.status = ProposalStatus.COMPLETED;
321
+ proposal.completed_at = Date.now();
322
+ proposal.completion_proof = proof;
323
+ proposal.completion_sig = sig;
324
+ proposal.completed_by = completerId;
325
+ proposal.updated_at = Date.now();
326
+
327
+ return { proposal };
328
+ }
329
+
330
+ /**
331
+ * Dispute a proposal
332
+ */
333
+ dispute(
334
+ id: string,
335
+ disputerId: string,
336
+ sig: string,
337
+ reason: string
338
+ ): ProposalResult {
339
+ const proposal = this.get(id);
340
+ if (!proposal) {
341
+ return { error: 'PROPOSAL_NOT_FOUND' };
342
+ }
343
+
344
+ // Can only dispute accepted proposals
345
+ if (proposal.status !== ProposalStatus.ACCEPTED) {
346
+ return { error: 'PROPOSAL_NOT_ACCEPTED', status: proposal.status };
347
+ }
348
+
349
+ // Either party can dispute
350
+ if (proposal.from !== disputerId && proposal.to !== disputerId) {
351
+ return { error: 'NOT_PROPOSAL_PARTY' };
352
+ }
353
+
354
+ proposal.status = ProposalStatus.DISPUTED;
355
+ proposal.dispute_reason = reason;
356
+ proposal.dispute_sig = sig;
357
+ proposal.disputed_by = disputerId;
358
+ proposal.disputed_at = Date.now();
359
+ proposal.updated_at = Date.now();
360
+
361
+ return { proposal };
362
+ }
363
+
364
+ /**
365
+ * List proposals for an agent
366
+ */
367
+ listByAgent(agentId: string, options: ListOptions = {}): StoredProposal[] {
368
+ const ids = this.byAgent.get(agentId) || new Set<string>();
369
+ let proposals = Array.from(ids)
370
+ .map(id => this.get(id))
371
+ .filter((p): p is StoredProposal => p !== null);
372
+
373
+ // Filter by status
374
+ if (options.status) {
375
+ proposals = proposals.filter(p => p.status === options.status);
376
+ }
377
+
378
+ // Filter by role (from/to)
379
+ if (options.role === 'from') {
380
+ proposals = proposals.filter(p => p.from === agentId);
381
+ } else if (options.role === 'to') {
382
+ proposals = proposals.filter(p => p.to === agentId);
383
+ }
384
+
385
+ // Sort by created_at descending
386
+ proposals.sort((a, b) => b.created_at - a.created_at);
387
+
388
+ // Limit
389
+ if (options.limit) {
390
+ proposals = proposals.slice(0, options.limit);
391
+ }
392
+
393
+ return proposals;
394
+ }
395
+
396
+ /**
397
+ * Index a proposal by agent
398
+ */
399
+ private _indexAgent(agentId: string, proposalId: string): void {
400
+ if (!this.byAgent.has(agentId)) {
401
+ this.byAgent.set(agentId, new Set());
402
+ }
403
+ this.byAgent.get(agentId)!.add(proposalId);
404
+ }
405
+
406
+ /**
407
+ * Clean up expired proposals (older than 24 hours after expiration)
408
+ */
409
+ cleanupExpired(): void {
410
+ const cutoff = Date.now() - (24 * 60 * 60 * 1000);
411
+
412
+ for (const [id, proposal] of this.proposals) {
413
+ if (proposal.expires && proposal.expires < cutoff) {
414
+ this.proposals.delete(id);
415
+
416
+ // Remove from agent indices
417
+ const fromSet = this.byAgent.get(proposal.from);
418
+ if (fromSet) fromSet.delete(id);
419
+
420
+ const toSet = this.byAgent.get(proposal.to);
421
+ if (toSet) toSet.delete(id);
422
+ }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Stop the cleanup interval
428
+ */
429
+ close(): void {
430
+ if (this.cleanupInterval) {
431
+ clearInterval(this.cleanupInterval);
432
+ this.cleanupInterval = null;
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Get stats about the proposal store
438
+ */
439
+ stats(): ProposalStats {
440
+ const byStatus: Record<string, number> = {};
441
+ for (const proposal of this.proposals.values()) {
442
+ byStatus[proposal.status] = (byStatus[proposal.status] || 0) + 1;
443
+ }
444
+
445
+ return {
446
+ total: this.proposals.size,
447
+ byStatus,
448
+ agents: this.byAgent.size
449
+ };
450
+ }
451
+ }
452
+
453
+ // ============ Helper Functions ============
454
+
455
+ /**
456
+ * Format a proposal for display/transmission
457
+ */
458
+ export function formatProposal(proposal: StoredProposal): FormattedProposal {
459
+ return {
460
+ id: proposal.id,
461
+ from: proposal.from,
462
+ to: proposal.to,
463
+ task: proposal.task,
464
+ amount: proposal.amount,
465
+ currency: proposal.currency,
466
+ payment_code: proposal.payment_code,
467
+ terms: proposal.terms,
468
+ expires: proposal.expires,
469
+ status: proposal.status,
470
+ created_at: proposal.created_at,
471
+ sig: proposal.sig,
472
+ elo_stake: proposal.proposer_stake
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Format a proposal response (accept/reject/complete/dispute)
478
+ */
479
+ export function formatProposalResponse(
480
+ proposal: StoredProposal,
481
+ responseType: 'accept' | 'reject' | 'complete' | 'dispute' | string
482
+ ): ProposalResponse {
483
+ const base: ProposalResponseBase = {
484
+ proposal_id: proposal.id,
485
+ status: proposal.status,
486
+ updated_at: proposal.updated_at
487
+ };
488
+
489
+ switch (responseType) {
490
+ case 'accept':
491
+ return {
492
+ ...base,
493
+ from: proposal.from,
494
+ to: proposal.to,
495
+ payment_code: proposal.response_payment_code,
496
+ sig: proposal.response_sig,
497
+ proposer_stake: proposal.proposer_stake,
498
+ acceptor_stake: proposal.acceptor_stake
499
+ } as AcceptResponse;
500
+
501
+ case 'reject':
502
+ return {
503
+ ...base,
504
+ from: proposal.from,
505
+ to: proposal.to,
506
+ reason: proposal.reject_reason,
507
+ sig: proposal.response_sig
508
+ } as RejectResponse;
509
+
510
+ case 'complete':
511
+ return {
512
+ ...base,
513
+ from: proposal.from,
514
+ to: proposal.to,
515
+ completed_by: proposal.completed_by,
516
+ completed_at: proposal.completed_at,
517
+ proof: proposal.completion_proof,
518
+ sig: proposal.completion_sig,
519
+ elo_stakes: {
520
+ proposer: proposal.proposer_stake || 0,
521
+ acceptor: proposal.acceptor_stake || 0
522
+ }
523
+ } as CompleteResponse;
524
+
525
+ case 'dispute':
526
+ return {
527
+ ...base,
528
+ from: proposal.from,
529
+ to: proposal.to,
530
+ disputed_by: proposal.disputed_by,
531
+ disputed_at: proposal.disputed_at,
532
+ reason: proposal.dispute_reason,
533
+ sig: proposal.dispute_sig,
534
+ elo_stakes: {
535
+ proposer: proposal.proposer_stake || 0,
536
+ acceptor: proposal.acceptor_stake || 0
537
+ }
538
+ } as DisputeResponse;
539
+
540
+ default:
541
+ return base;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Create proposal content string for signing
547
+ * This ensures both parties sign the same canonical data
548
+ */
549
+ export function getProposalSigningContent(proposal: {
550
+ to: string;
551
+ task: string;
552
+ amount?: number | null;
553
+ currency?: string | null;
554
+ payment_code?: string | null;
555
+ expires?: number | null;
556
+ elo_stake?: number | null;
557
+ }): string {
558
+ const fields = [
559
+ proposal.to,
560
+ proposal.task,
561
+ proposal.amount || '',
562
+ proposal.currency || '',
563
+ proposal.payment_code || '',
564
+ proposal.expires || '',
565
+ proposal.elo_stake || ''
566
+ ];
567
+ return fields.join('|');
568
+ }
569
+
570
+ /**
571
+ * Create accept content string for signing
572
+ * @param proposalId - The proposal being accepted
573
+ * @param payment_code - Optional payment code
574
+ * @param elo_stake - Optional ELO stake from acceptor
575
+ */
576
+ export function getAcceptSigningContent(
577
+ proposalId: string,
578
+ payment_code: string = '',
579
+ elo_stake: string | number = ''
580
+ ): string {
581
+ return `ACCEPT|${proposalId}|${payment_code}|${elo_stake}`;
582
+ }
583
+
584
+ /**
585
+ * Create reject content string for signing
586
+ */
587
+ export function getRejectSigningContent(
588
+ proposalId: string,
589
+ reason: string = ''
590
+ ): string {
591
+ return `REJECT|${proposalId}|${reason}`;
592
+ }
593
+
594
+ /**
595
+ * Create complete content string for signing
596
+ */
597
+ export function getCompleteSigningContent(
598
+ proposalId: string,
599
+ proof: string = ''
600
+ ): string {
601
+ return `COMPLETE|${proposalId}|${proof}`;
602
+ }
603
+
604
+ /**
605
+ * Create dispute content string for signing
606
+ */
607
+ export function getDisputeSigningContent(
608
+ proposalId: string,
609
+ reason: string
610
+ ): string {
611
+ return `DISPUTE|${proposalId}|${reason}`;
612
+ }
package/lib/protocol.js CHANGED
@@ -29,7 +29,11 @@ export const ClientMessageType = {
29
29
  SET_PRESENCE: 'SET_PRESENCE',
30
30
  // Identity verification message types
31
31
  VERIFY_REQUEST: 'VERIFY_REQUEST',
32
- VERIFY_RESPONSE: 'VERIFY_RESPONSE'
32
+ VERIFY_RESPONSE: 'VERIFY_RESPONSE',
33
+ // Admin message types
34
+ ADMIN_APPROVE: 'ADMIN_APPROVE',
35
+ ADMIN_REVOKE: 'ADMIN_REVOKE',
36
+ ADMIN_LIST: 'ADMIN_LIST',
33
37
  };
34
38
 
35
39
  // Server -> Client message types
@@ -59,7 +63,9 @@ export const ServerMessageType = {
59
63
  VERIFY_REQUEST: 'VERIFY_REQUEST',
60
64
  VERIFY_RESPONSE: 'VERIFY_RESPONSE',
61
65
  VERIFY_SUCCESS: 'VERIFY_SUCCESS',
62
- VERIFY_FAILED: 'VERIFY_FAILED'
66
+ VERIFY_FAILED: 'VERIFY_FAILED',
67
+ // Admin response
68
+ ADMIN_RESULT: 'ADMIN_RESULT',
63
69
  };
64
70
 
65
71
  // Error codes
@@ -84,7 +90,9 @@ export const ErrorCode = {
84
90
  // Verification errors
85
91
  VERIFICATION_FAILED: 'VERIFICATION_FAILED',
86
92
  VERIFICATION_EXPIRED: 'VERIFICATION_EXPIRED',
87
- NO_PUBKEY: 'NO_PUBKEY'
93
+ NO_PUBKEY: 'NO_PUBKEY',
94
+ // Allowlist errors
95
+ NOT_ALLOWED: 'NOT_ALLOWED',
88
96
  };
89
97
 
90
98
  // Presence status
@@ -92,7 +100,8 @@ export const PresenceStatus = {
92
100
  ONLINE: 'online',
93
101
  AWAY: 'away',
94
102
  BUSY: 'busy',
95
- OFFLINE: 'offline'
103
+ OFFLINE: 'offline',
104
+ LISTENING: 'listening'
96
105
  };
97
106
 
98
107
  // Proposal status
@@ -281,7 +290,7 @@ export function validateClientMessage(raw) {
281
290
  if (msg.expires !== undefined && typeof msg.expires !== 'number') {
282
291
  return { valid: false, error: 'expires must be a number (seconds)' };
283
292
  }
284
- if (msg.elo_stake !== undefined) {
293
+ if (msg.elo_stake !== undefined && msg.elo_stake !== null) {
285
294
  if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
286
295
  return { valid: false, error: 'elo_stake must be a non-negative integer' };
287
296
  }
@@ -297,7 +306,7 @@ export function validateClientMessage(raw) {
297
306
  return { valid: false, error: 'Accept must be signed' };
298
307
  }
299
308
  // Optional: elo_stake for acceptor's stake
300
- if (msg.elo_stake !== undefined) {
309
+ if (msg.elo_stake !== undefined && msg.elo_stake !== null) {
301
310
  if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
302
311
  return { valid: false, error: 'elo_stake must be a non-negative integer' };
303
312
  }
@@ -366,7 +375,7 @@ export function validateClientMessage(raw) {
366
375
 
367
376
  case ClientMessageType.SET_PRESENCE:
368
377
  // Set presence requires: status (online, away, busy, offline)
369
- const validStatuses = ['online', 'away', 'busy', 'offline'];
378
+ const validStatuses = ['online', 'away', 'busy', 'offline', 'listening'];
370
379
  if (!msg.status || !validStatuses.includes(msg.status)) {
371
380
  return { valid: false, error: `Invalid presence status. Must be one of: ${validStatuses.join(', ')}` };
372
381
  }
@@ -408,6 +417,30 @@ export function validateClientMessage(raw) {
408
417
  }
409
418
  break;
410
419
 
420
+ case ClientMessageType.ADMIN_APPROVE:
421
+ if (!msg.pubkey || typeof msg.pubkey !== 'string') {
422
+ return { valid: false, error: 'Missing or invalid pubkey' };
423
+ }
424
+ if (!msg.admin_key || typeof msg.admin_key !== 'string') {
425
+ return { valid: false, error: 'Missing admin_key' };
426
+ }
427
+ break;
428
+
429
+ case ClientMessageType.ADMIN_REVOKE:
430
+ if (!msg.pubkey && !msg.agent_id) {
431
+ return { valid: false, error: 'Missing pubkey or agent_id' };
432
+ }
433
+ if (!msg.admin_key || typeof msg.admin_key !== 'string') {
434
+ return { valid: false, error: 'Missing admin_key' };
435
+ }
436
+ break;
437
+
438
+ case ClientMessageType.ADMIN_LIST:
439
+ if (!msg.admin_key || typeof msg.admin_key !== 'string') {
440
+ return { valid: false, error: 'Missing admin_key' };
441
+ }
442
+ break;
443
+
411
444
  default:
412
445
  return { valid: false, error: `Unknown message type: ${msg.type}` };
413
446
  }