@tjamescouch/agentchat 0.11.0 → 0.13.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/bin/agentchat.js CHANGED
@@ -48,6 +48,10 @@ import {
48
48
  DEFAULT_RATINGS_PATH,
49
49
  DEFAULT_RATING
50
50
  } from '../lib/reputation.js';
51
+ import {
52
+ ServerDirectory,
53
+ DEFAULT_DIRECTORY_PATH
54
+ } from '../lib/server-directory.js';
51
55
 
52
56
  program
53
57
  .name('agentchat')
@@ -504,6 +508,39 @@ program
504
508
  }
505
509
  });
506
510
 
511
+ // Verify agent identity command
512
+ program
513
+ .command('verify <server> <agent>')
514
+ .description('Verify another agent\'s identity via challenge-response')
515
+ .option('-i, --identity <file>', 'Path to identity file (required)', DEFAULT_IDENTITY_PATH)
516
+ .action(async (server, agent, options) => {
517
+ try {
518
+ const client = new AgentChatClient({ server, identity: options.identity });
519
+ await client.connect();
520
+
521
+ console.log(`Verifying identity of ${agent}...`);
522
+
523
+ const result = await client.verify(agent);
524
+
525
+ if (result.verified) {
526
+ console.log('Identity verified!');
527
+ console.log(` Agent: ${result.agent}`);
528
+ console.log(` Public Key:`);
529
+ console.log(result.pubkey.split('\n').map(line => ` ${line}`).join('\n'));
530
+ } else {
531
+ console.log('Verification failed!');
532
+ console.log(` Target: ${result.target}`);
533
+ console.log(` Reason: ${result.reason}`);
534
+ }
535
+
536
+ client.disconnect();
537
+ process.exit(result.verified ? 0 : 1);
538
+ } catch (err) {
539
+ console.error('Error:', err.message);
540
+ process.exit(1);
541
+ }
542
+ });
543
+
507
544
  // Identity management command
508
545
  program
509
546
  .command('identity')
@@ -511,6 +548,8 @@ program
511
548
  .option('-g, --generate', 'Generate new keypair')
512
549
  .option('-s, --show', 'Show current identity')
513
550
  .option('-e, --export', 'Export public key for sharing (JSON to stdout)')
551
+ .option('-r, --rotate', 'Rotate to new keypair (signs new key with old key)')
552
+ .option('--verify-chain', 'Verify the rotation chain')
514
553
  .option('-f, --file <path>', 'Identity file path', DEFAULT_IDENTITY_PATH)
515
554
  .option('-n, --name <name>', 'Agent name (for --generate)', `agent-${process.pid}`)
516
555
  .option('--force', 'Overwrite existing identity')
@@ -551,6 +590,54 @@ program
551
590
  const identity = await Identity.load(options.file);
552
591
  console.log(JSON.stringify(identity.export(), null, 2));
553
592
 
593
+ } else if (options.rotate) {
594
+ // Rotate to new keypair
595
+ const identity = await Identity.load(options.file);
596
+ const oldAgentId = identity.getAgentId();
597
+ const oldFingerprint = identity.getFingerprint();
598
+
599
+ console.log('Rotating identity...');
600
+ console.log(` Old Agent ID: ${oldAgentId}`);
601
+ console.log(` Old Fingerprint: ${oldFingerprint}`);
602
+
603
+ const record = identity.rotate();
604
+ await identity.save(options.file);
605
+
606
+ console.log('');
607
+ console.log('Rotation complete:');
608
+ console.log(` New Agent ID: ${identity.getAgentId()}`);
609
+ console.log(` New Fingerprint: ${identity.getFingerprint()}`);
610
+ console.log(` Total rotations: ${identity.rotations.length}`);
611
+ console.log('');
612
+ console.log('The new key has been signed by the old key for chain of custody.');
613
+ console.log('Share the rotation record to prove key continuity.');
614
+
615
+ } else if (options.verifyChain) {
616
+ // Verify rotation chain
617
+ const identity = await Identity.load(options.file);
618
+
619
+ if (identity.rotations.length === 0) {
620
+ console.log('No rotations to verify (original identity).');
621
+ console.log(` Agent ID: ${identity.getAgentId()}`);
622
+ process.exit(0);
623
+ }
624
+
625
+ console.log(`Verifying rotation chain (${identity.rotations.length} rotation(s))...`);
626
+ const result = identity.verifyRotationChain();
627
+
628
+ if (result.valid) {
629
+ console.log('Chain verified successfully!');
630
+ console.log(` Original Agent ID: ${identity.getOriginalAgentId()}`);
631
+ console.log(` Current Agent ID: ${identity.getAgentId()}`);
632
+ console.log(` Rotations: ${identity.rotations.length}`);
633
+ } else {
634
+ console.error('Chain verification FAILED:');
635
+ for (const error of result.errors) {
636
+ console.error(` - ${error}`);
637
+ }
638
+ process.exit(1);
639
+ }
640
+
554
641
  } else {
555
642
  // Default: show if exists, otherwise show help
556
643
  const exists = await Identity.exists(options.file);
@@ -1117,6 +1204,91 @@ program
1117
1204
  }
1118
1205
  });
1119
1206
 
1207
+ // Discover command - find public AgentChat servers
1208
+ program
1209
+ .command('discover')
1210
+ .description('Discover available AgentChat servers')
1211
+ .option('--add <url>', 'Add a server to the directory')
1212
+ .option('--remove <url>', 'Remove a server from the directory')
1213
+ .option('--name <name>', 'Server name (for --add)')
1214
+ .option('--description <desc>', 'Server description (for --add)')
1215
+ .option('--region <region>', 'Server region (for --add)')
1216
+ .option('--online', 'Only show online servers')
1217
+ .option('--json', 'Output as JSON')
1218
+ .option('--no-check', 'List servers without health check')
1219
+ .option('--directory <path>', 'Custom directory file path', DEFAULT_DIRECTORY_PATH)
1220
+ .action(async (options) => {
1221
+ try {
1222
+ const directory = new ServerDirectory({ directoryPath: options.directory });
1223
+ await directory.load();
1224
+
1225
+ // Add server
1226
+ if (options.add) {
1227
+ await directory.addServer({
1228
+ url: options.add,
1229
+ name: options.name || options.add,
1230
+ description: options.description || '',
1231
+ region: options.region || 'unknown'
1232
+ });
1233
+ console.log(`Added server: ${options.add}`);
1234
+ process.exit(0);
1235
+ }
1236
+
1237
+ // Remove server
1238
+ if (options.remove) {
1239
+ await directory.removeServer(options.remove);
1240
+ console.log(`Removed server: ${options.remove}`);
1241
+ process.exit(0);
1242
+ }
1243
+
1244
+ // List/discover servers
1245
+ let servers;
1246
+ if (options.check === false) {
1247
+ servers = directory.list().map(s => ({ ...s, status: 'unknown' }));
1248
+ } else {
1249
+ console.error('Checking server status...');
1250
+ servers = await directory.discover({ onlineOnly: options.online });
1251
+ }
1252
+
1253
+ if (options.json) {
1254
+ console.log(JSON.stringify(servers, null, 2));
1255
+ } else {
1256
+ if (servers.length === 0) {
1257
+ console.log('No servers found.');
1258
+ } else {
1259
+ console.log(`\nFound ${servers.length} server(s):\n`);
1260
+ for (const server of servers) {
1261
+ const statusIcon = server.status === 'online' ? '\u2713' :
1262
+ server.status === 'offline' ? '\u2717' : '?';
1263
+ console.log(` ${statusIcon} ${server.name}`);
1264
+ console.log(` URL: ${server.url}`);
1265
+ console.log(` Status: ${server.status}`);
1266
+ if (server.description) {
1267
+ console.log(` Description: ${server.description}`);
1268
+ }
1269
+ if (server.region) {
1270
+ console.log(` Region: ${server.region}`);
1271
+ }
1272
+ if (server.health) {
1273
+ console.log(` Agents: ${server.health.agents?.connected || 0}`);
1274
+ console.log(` Uptime: ${server.health.uptime_seconds || 0}s`);
1275
+ }
1276
+ if (server.error) {
1277
+ console.log(` Error: ${server.error}`);
1278
+ }
1279
+ console.log('');
1280
+ }
1281
+ }
1282
+ console.log(`Directory: ${options.directory}`);
1283
+ }
1284
+
1285
+ process.exit(0);
1286
+ } catch (err) {
1287
+ console.error('Error:', err.message);
1288
+ process.exit(1);
1289
+ }
1290
+ });
1291
+
1120
1292
  // Deploy command
1121
1293
  program
1122
1294
  .command('deploy')
package/lib/chat.py CHANGED
@@ -103,6 +103,62 @@ def poll_new(paths: dict):
103
103
  return messages
104
104
 
105
105
 
106
+ def wait_for_messages(paths: dict, interval: float = 2.0, timeout: float = 300.0):
107
+ """Block until new messages arrive. Returns messages or empty list on timeout."""
108
+ import signal
109
+ import time
110
+
111
+ stop_file = paths["inbox"].parent.parent.parent / "stop"
112
+
113
+ # Handle interrupts gracefully
114
+ interrupted = False
115
+ def handle_signal(signum, frame):
116
+ nonlocal interrupted
117
+ interrupted = True
118
+
119
+ old_handler = signal.signal(signal.SIGINT, handle_signal)
120
+
121
+ try:
122
+ start = time.time()
123
+ while not interrupted and (time.time() - start) < timeout:
124
+ # Check stop file
125
+ if stop_file.exists():
126
+ try:
127
+ stop_file.unlink()
128
+ except FileNotFoundError:
129
+ pass
130
+ return [] # Signal to stop
131
+
132
+ # Check semaphore
133
+ if paths["newdata"].exists():
134
+ messages = read_inbox(paths)
135
+ # Filter out @server messages
136
+ messages = [m for m in messages if m.get("from") != "@server"]
137
+
138
+ if messages:
139
+ # Update timestamp
140
+ max_ts = max(m.get("ts", 0) for m in messages)
141
+ set_last_ts(paths, max_ts)
142
+ # Clear semaphore
143
+ try:
144
+ paths["newdata"].unlink()
145
+ except FileNotFoundError:
146
+ pass
147
+ return messages
148
+
149
+ # Semaphore but no messages after filtering - clear and continue
150
+ try:
151
+ paths["newdata"].unlink()
152
+ except FileNotFoundError:
153
+ pass
154
+
155
+ time.sleep(interval)
156
+
157
+ return [] # Timeout
158
+ finally:
159
+ signal.signal(signal.SIGINT, old_handler)
160
+
161
+
106
162
  def main():
107
163
  parser = argparse.ArgumentParser(description="AgentChat daemon helper")
108
164
  parser.add_argument("--daemon-dir", type=Path, default=DEFAULT_DAEMON_DIR,
@@ -133,6 +189,11 @@ def main():
133
189
  # poll command - efficient check using semaphore
134
190
  poll_p = subparsers.add_parser("poll", help="Poll for new messages (uses semaphore, silent if none)")
135
191
 
192
+ # wait command - block until messages arrive
193
+ wait_p = subparsers.add_parser("wait", help="Block until new messages arrive")
194
+ wait_p.add_argument("--interval", type=float, default=2.0, help="Poll interval in seconds")
195
+ wait_p.add_argument("--timeout", type=float, default=300.0, help="Max wait time in seconds")
196
+
136
197
  args = parser.parse_args()
137
198
  paths = get_paths(args.daemon_dir)
138
199
 
@@ -170,6 +231,11 @@ def main():
170
231
  print(json.dumps(msg))
171
232
  # Empty list = semaphore existed but no new messages after filtering
172
233
 
234
+ elif args.command == "wait":
235
+ messages = wait_for_messages(paths, interval=args.interval, timeout=args.timeout)
236
+ for msg in messages:
237
+ print(json.dumps(msg))
238
+
173
239
 
174
240
  if __name__ == "__main__":
175
241
  main()
package/lib/client.js CHANGED
@@ -10,7 +10,8 @@ import {
10
10
  ServerMessageType,
11
11
  createMessage,
12
12
  serialize,
13
- parse
13
+ parse,
14
+ generateNonce
14
15
  } from './protocol.js';
15
16
  import { Identity } from './identity.js';
16
17
  import {
@@ -532,6 +533,118 @@ export class AgentChatClient extends EventEmitter {
532
533
  });
533
534
  }
534
535
 
536
+ // ===== IDENTITY VERIFICATION METHODS =====
537
+
538
+ /**
539
+ * Request identity verification from another agent
540
+ * Sends a challenge nonce that the target must sign to prove they control their identity
541
+ * @param {string} target - Target agent to verify (@id)
542
+ * @returns {Promise<object>} Verification result with pubkey if successful
543
+ */
544
+ async verify(target) {
545
+ const targetAgent = target.startsWith('@') ? target : `@${target}`;
546
+ const nonce = generateNonce();
547
+
548
+ const msg = {
549
+ type: ClientMessageType.VERIFY_REQUEST,
550
+ target: targetAgent,
551
+ nonce
552
+ };
553
+
554
+ this._send(msg);
555
+
556
+ return new Promise((resolve, reject) => {
557
+ const timeout = setTimeout(() => {
558
+ this.removeListener('verify_success', onSuccess);
559
+ this.removeListener('verify_failed', onFailed);
560
+ this.removeListener('error', onError);
561
+ reject(new Error('Verification timeout'));
562
+ }, 35000); // Slightly longer than server timeout
563
+
564
+ const onSuccess = (response) => {
565
+ if (response.agent === targetAgent || response.target === targetAgent) {
566
+ clearTimeout(timeout);
567
+ this.removeListener('verify_failed', onFailed);
568
+ this.removeListener('error', onError);
569
+ resolve({
570
+ verified: true,
571
+ agent: response.agent,
572
+ pubkey: response.pubkey,
573
+ request_id: response.request_id
574
+ });
575
+ }
576
+ };
577
+
578
+ const onFailed = (response) => {
579
+ if (response.target === targetAgent) {
580
+ clearTimeout(timeout);
581
+ this.removeListener('verify_success', onSuccess);
582
+ this.removeListener('error', onError);
583
+ resolve({
584
+ verified: false,
585
+ target: response.target,
586
+ reason: response.reason,
587
+ request_id: response.request_id
588
+ });
589
+ }
590
+ };
591
+
592
+ const onError = (err) => {
593
+ clearTimeout(timeout);
594
+ this.removeListener('verify_success', onSuccess);
595
+ this.removeListener('verify_failed', onFailed);
596
+ reject(new Error(err.message));
597
+ };
598
+
599
+ this.on('verify_success', onSuccess);
600
+ this.on('verify_failed', onFailed);
601
+ this.once('error', onError);
602
+ });
603
+ }
604
+
605
+ /**
606
+ * Respond to a verification request by signing the nonce
607
+ * This is typically called automatically when a VERIFY_REQUEST is received
608
+ * @param {string} requestId - The verification request ID
609
+ * @param {string} nonce - The nonce to sign
610
+ */
611
+ async respondToVerification(requestId, nonce) {
612
+ if (!this._identity || !this._identity.privkey) {
613
+ throw new Error('Responding to verification requires persistent identity.');
614
+ }
615
+
616
+ const sig = this._identity.sign(nonce);
617
+
618
+ const msg = {
619
+ type: ClientMessageType.VERIFY_RESPONSE,
620
+ request_id: requestId,
621
+ nonce,
622
+ sig
623
+ };
624
+
625
+ this._send(msg);
626
+ }
627
+
628
+ /**
629
+ * Enable automatic verification response
630
+ * When enabled, the client will automatically respond to VERIFY_REQUEST messages
631
+ * @param {boolean} enabled - Whether to enable auto-response
632
+ */
633
+ enableAutoVerification(enabled = true) {
634
+ if (enabled) {
635
+ this._autoVerifyHandler = (msg) => {
636
+ if (msg.request_id && msg.nonce && msg.from) {
637
+ this.respondToVerification(msg.request_id, msg.nonce)
638
+ .catch(err => this.emit('error', { message: `Auto-verification failed: ${err.message}` }));
639
+ }
640
+ };
641
+ this.on('verify_request', this._autoVerifyHandler);
642
+ } else if (this._autoVerifyHandler) {
643
+ this.removeListener('verify_request', this._autoVerifyHandler);
644
+ this._autoVerifyHandler = null;
645
+ }
646
+ }
647
+
535
648
  _send(msg) {
536
649
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
537
650
  this.ws.send(serialize(msg));
@@ -630,6 +743,23 @@ export class AgentChatClient extends EventEmitter {
630
743
  this.emit('search_results', msg);
631
744
  this.emit('message', msg);
632
745
  break;
746
+
747
+ // Identity verification messages
748
+ case ServerMessageType.VERIFY_REQUEST:
749
+ this.emit('verify_request', msg);
750
+ break;
751
+
752
+ case ServerMessageType.VERIFY_RESPONSE:
753
+ this.emit('verify_response', msg);
754
+ break;
755
+
756
+ case ServerMessageType.VERIFY_SUCCESS:
757
+ this.emit('verify_success', msg);
758
+ break;
759
+
760
+ case ServerMessageType.VERIFY_FAILED:
761
+ this.emit('verify_failed', msg);
762
+ break;
633
763
  }
634
764
  }
635
765
  }