@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,94 @@
1
+ /**
2
+ * Admin Handlers
3
+ * Handles allowlist administration commands
4
+ */
5
+
6
+ import {
7
+ ServerMessageType,
8
+ ErrorCode,
9
+ createMessage,
10
+ createError,
11
+ } from '../../protocol.js';
12
+
13
+ /**
14
+ * Handle ADMIN_APPROVE command - add a pubkey to the allowlist
15
+ */
16
+ export function handleAdminApprove(server, ws, msg) {
17
+ if (!server.allowlist) {
18
+ server._send(ws, createError(ErrorCode.INVALID_MSG, 'Allowlist not configured'));
19
+ return;
20
+ }
21
+
22
+ if (!msg.pubkey) {
23
+ server._send(ws, createError(ErrorCode.INVALID_MSG, 'Missing pubkey'));
24
+ return;
25
+ }
26
+
27
+ const result = server.allowlist.approve(msg.pubkey, msg.admin_key, msg.note || '');
28
+
29
+ if (!result.success) {
30
+ server._send(ws, createError(ErrorCode.AUTH_REQUIRED, result.error));
31
+ return;
32
+ }
33
+
34
+ server._log('admin_approve', { agentId: result.agentId });
35
+ server._send(ws, createMessage(ServerMessageType.ADMIN_RESULT, {
36
+ action: 'approve',
37
+ success: true,
38
+ agentId: `@${result.agentId}`,
39
+ }));
40
+ }
41
+
42
+ /**
43
+ * Handle ADMIN_REVOKE command - remove a pubkey from the allowlist
44
+ */
45
+ export function handleAdminRevoke(server, ws, msg) {
46
+ if (!server.allowlist) {
47
+ server._send(ws, createError(ErrorCode.INVALID_MSG, 'Allowlist not configured'));
48
+ return;
49
+ }
50
+
51
+ const identifier = msg.pubkey || msg.agent_id;
52
+ if (!identifier) {
53
+ server._send(ws, createError(ErrorCode.INVALID_MSG, 'Missing pubkey or agent_id'));
54
+ return;
55
+ }
56
+
57
+ const result = server.allowlist.revoke(identifier, msg.admin_key);
58
+
59
+ if (!result.success) {
60
+ const code = result.error === 'invalid admin key' ? ErrorCode.AUTH_REQUIRED : ErrorCode.AGENT_NOT_FOUND;
61
+ server._send(ws, createError(code, result.error));
62
+ return;
63
+ }
64
+
65
+ server._log('admin_revoke', { identifier });
66
+ server._send(ws, createMessage(ServerMessageType.ADMIN_RESULT, {
67
+ action: 'revoke',
68
+ success: true,
69
+ }));
70
+ }
71
+
72
+ /**
73
+ * Handle ADMIN_LIST command - list all approved entries
74
+ */
75
+ export function handleAdminList(server, ws, msg) {
76
+ if (!server.allowlist) {
77
+ server._send(ws, createError(ErrorCode.INVALID_MSG, 'Allowlist not configured'));
78
+ return;
79
+ }
80
+
81
+ // Validate admin key
82
+ if (!server.allowlist._validateAdminKey(msg.admin_key)) {
83
+ server._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Invalid admin key'));
84
+ return;
85
+ }
86
+
87
+ const entries = server.allowlist.list();
88
+ server._send(ws, createMessage(ServerMessageType.ADMIN_RESULT, {
89
+ action: 'list',
90
+ entries,
91
+ enabled: server.allowlist.enabled,
92
+ strict: server.allowlist.strict,
93
+ }));
94
+ }
@@ -24,6 +24,21 @@ export function handleIdentify(server, ws, msg) {
24
24
  return;
25
25
  }
26
26
 
27
+ // Allowlist check (before any state changes)
28
+ if (server.allowlist && server.allowlist.enabled) {
29
+ const check = server.allowlist.check(msg.pubkey || null);
30
+ if (!check.allowed) {
31
+ server._log('allowlist_rejected', {
32
+ ip: ws._realIp,
33
+ name: msg.name,
34
+ hasPubkey: !!msg.pubkey,
35
+ reason: check.reason
36
+ });
37
+ server._send(ws, createError(ErrorCode.NOT_ALLOWED, check.reason));
38
+ return;
39
+ }
40
+ }
41
+
27
42
  let id;
28
43
 
29
44
  // Use pubkey-derived stable ID if pubkey provided
@@ -82,6 +97,7 @@ export function handleIdentify(server, ws, msg) {
82
97
 
83
98
  server._send(ws, createMessage(ServerMessageType.WELCOME, {
84
99
  agent_id: `@${id}`,
100
+ name: msg.name,
85
101
  server: server.serverName
86
102
  }));
87
103
  }
@@ -33,6 +33,7 @@ export function handleMsg(server, ws, msg) {
33
33
 
34
34
  const outMsg = createMessage(ServerMessageType.MSG, {
35
35
  from: `@${agent.id}`,
36
+ from_name: agent.name,
36
37
  to: msg.to,
37
38
  content: msg.content,
38
39
  ...(msg.sig && { sig: msg.sig })
@@ -108,14 +109,15 @@ export function handleJoin(server, ws, msg) {
108
109
  // Notify others
109
110
  server._broadcast(msg.channel, createMessage(ServerMessageType.AGENT_JOINED, {
110
111
  channel: msg.channel,
111
- agent: `@${agent.id}`
112
+ agent: `@${agent.id}`,
113
+ name: agent.name
112
114
  }), ws);
113
115
 
114
116
  // Send confirmation with agent list
115
117
  const agentList = [];
116
118
  for (const memberWs of channel.agents) {
117
119
  const member = server.agents.get(memberWs);
118
- if (member) agentList.push(`@${member.id}`);
120
+ if (member) agentList.push({ id: `@${member.id}`, name: member.name });
119
121
  }
120
122
 
121
123
  server._send(ws, createMessage(ServerMessageType.JOINED, {
@@ -130,7 +132,7 @@ export function handleJoin(server, ws, msg) {
130
132
  server._send(ws, createMessage(ServerMessageType.MSG, {
131
133
  from: '@server',
132
134
  to: msg.channel,
133
- content: `Welcome to ${msg.channel}, @${agent.id}! Say hello to introduce yourself and start collaborating with other agents.`
135
+ content: `Welcome to ${msg.channel}, ${agent.name} (@${agent.id})! Say hello to introduce yourself and start collaborating with other agents.`
134
136
  }));
135
137
 
136
138
  // Prompt existing agents to engage with the new joiner (if there are others)
@@ -138,7 +140,7 @@ export function handleJoin(server, ws, msg) {
138
140
  for (const memberWs of channel.agents) {
139
141
  if (memberWs !== ws) {
140
142
  const member = server.agents.get(memberWs);
141
- if (member) otherAgents.push({ ws: memberWs, id: member.id });
143
+ if (member) otherAgents.push({ ws: memberWs, id: member.id, name: member.name });
142
144
  }
143
145
  }
144
146
 
@@ -146,7 +148,7 @@ export function handleJoin(server, ws, msg) {
146
148
  const welcomePrompt = createMessage(ServerMessageType.MSG, {
147
149
  from: '@server',
148
150
  to: msg.channel,
149
- content: `Hey ${otherAgents.map(a => `@${a.id}`).join(', ')} - new agent @${agent.id} just joined! Say hi and share what you're working on.`
151
+ content: `Hey ${otherAgents.map(a => `${a.name} (@${a.id})`).join(', ')} - new agent ${agent.name} (@${agent.id}) just joined! Say hi and share what you're working on.`
150
152
  });
151
153
 
152
154
  for (const other of otherAgents) {
@@ -179,7 +181,8 @@ export function handleLeave(server, ws, msg) {
179
181
  // Notify others
180
182
  server._broadcast(msg.channel, createMessage(ServerMessageType.AGENT_LEFT, {
181
183
  channel: msg.channel,
182
- agent: `@${agent.id}`
184
+ agent: `@${agent.id}`,
185
+ name: agent.name
183
186
  }));
184
187
 
185
188
  server._send(ws, createMessage(ServerMessageType.LEFT, {
@@ -189,8 +192,11 @@ export function handleLeave(server, ws, msg) {
189
192
 
190
193
  /**
191
194
  * Handle LIST_CHANNELS command
195
+ * Unauthenticated: returns channel names and agent count only
196
+ * Authenticated: returns full details
192
197
  */
193
198
  export function handleListChannels(server, ws) {
199
+ const agent = server.agents.get(ws);
194
200
  const list = [];
195
201
  for (const [name, channel] of server.channels) {
196
202
  if (!channel.inviteOnly) {
@@ -206,8 +212,15 @@ export function handleListChannels(server, ws) {
206
212
 
207
213
  /**
208
214
  * Handle LIST_AGENTS command
215
+ * Requires authentication to see agent details
209
216
  */
210
217
  export function handleListAgents(server, ws, msg) {
218
+ const agent = server.agents.get(ws);
219
+ if (!agent) {
220
+ server._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
221
+ return;
222
+ }
223
+
211
224
  const channel = server.channels.get(msg.channel);
212
225
  if (!channel) {
213
226
  server._send(ws, createError(ErrorCode.CHANNEL_NOT_FOUND, `Channel ${msg.channel} not found`));
@@ -34,6 +34,7 @@ export function handleSetPresence(server, ws, msg) {
34
34
  // Broadcast presence change to all channels the agent is in
35
35
  const presenceMsg = createMessage(ServerMessageType.PRESENCE_CHANGED, {
36
36
  agent_id: `@${agent.id}`,
37
+ name: agent.name,
37
38
  presence: agent.presence,
38
39
  status_text: agent.statusText
39
40
  });
@@ -9,14 +9,23 @@ import fs from 'fs/promises';
9
9
  import path from 'path';
10
10
 
11
11
  // Default public servers (can be extended)
12
- export const DEFAULT_SERVERS = [
13
- {
14
- name: 'AgentChat Public',
15
- url: 'wss://agentchat-server.fly.dev',
16
- description: 'Official public AgentChat server',
17
- region: 'global'
18
- }
19
- ];
12
+ export const DEFAULT_SERVERS = process.env.AGENTCHAT_PUBLIC === 'true'
13
+ ? [
14
+ {
15
+ name: 'AgentChat Public',
16
+ url: 'wss://agentchat-server.fly.dev',
17
+ description: 'Official public AgentChat server',
18
+ region: 'global'
19
+ }
20
+ ]
21
+ : [
22
+ {
23
+ name: 'AgentChat Local',
24
+ url: 'ws://localhost:6667',
25
+ description: 'Local AgentChat server',
26
+ region: 'local'
27
+ }
28
+ ];
20
29
 
21
30
  // Default directory file path
22
31
  export const DEFAULT_DIRECTORY_PATH = path.join(
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Server Directory
3
+ * Registry of known AgentChat servers for discovery
4
+ */
5
+
6
+ import http from 'http';
7
+ import https from 'https';
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+
11
+ export interface ServerEntry {
12
+ name: string;
13
+ url: string;
14
+ description?: string;
15
+ region?: string;
16
+ }
17
+
18
+ export interface HealthData {
19
+ agents?: {
20
+ connected?: number;
21
+ };
22
+ uptime_seconds?: number;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ export interface ServerWithStatus extends ServerEntry {
27
+ status: 'online' | 'offline' | 'unknown';
28
+ health?: HealthData;
29
+ error?: string;
30
+ checked_at?: string;
31
+ }
32
+
33
+ export interface ServerDirectoryFile {
34
+ version: number;
35
+ updated_at: string;
36
+ servers: ServerEntry[];
37
+ }
38
+
39
+ export interface ServerDirectoryOptions {
40
+ directoryPath?: string;
41
+ timeout?: number;
42
+ }
43
+
44
+ export interface DiscoverOptions {
45
+ onlineOnly?: boolean;
46
+ }
47
+
48
+ // Default public servers (can be extended)
49
+ export const DEFAULT_SERVERS: ServerEntry[] = process.env.AGENTCHAT_PUBLIC === 'true'
50
+ ? [
51
+ {
52
+ name: 'AgentChat Public',
53
+ url: 'wss://agentchat-server.fly.dev',
54
+ description: 'Official public AgentChat server',
55
+ region: 'global'
56
+ }
57
+ ]
58
+ : [
59
+ {
60
+ name: 'AgentChat Local',
61
+ url: 'ws://localhost:6667',
62
+ description: 'Local AgentChat server',
63
+ region: 'local'
64
+ }
65
+ ];
66
+
67
+ // Default directory file path
68
+ export const DEFAULT_DIRECTORY_PATH: string = path.join(
69
+ process.env.HOME || process.env.USERPROFILE || '.',
70
+ '.agentchat',
71
+ 'servers.json'
72
+ );
73
+
74
+ /**
75
+ * Server Directory for discovering AgentChat servers
76
+ */
77
+ export class ServerDirectory {
78
+ private directoryPath: string;
79
+ private servers: ServerEntry[];
80
+ private timeout: number;
81
+
82
+ constructor(options: ServerDirectoryOptions = {}) {
83
+ this.directoryPath = options.directoryPath || DEFAULT_DIRECTORY_PATH;
84
+ this.servers = [...DEFAULT_SERVERS];
85
+ this.timeout = options.timeout || 5000;
86
+ }
87
+
88
+ /**
89
+ * Load servers from directory file
90
+ */
91
+ async load(): Promise<this> {
92
+ try {
93
+ const data = await fs.readFile(this.directoryPath, 'utf8');
94
+ const loaded = JSON.parse(data) as ServerDirectoryFile;
95
+ if (Array.isArray(loaded.servers)) {
96
+ // Merge with defaults, avoiding duplicates by URL
97
+ const urls = new Set(this.servers.map(s => s.url));
98
+ for (const server of loaded.servers) {
99
+ if (!urls.has(server.url)) {
100
+ this.servers.push(server);
101
+ urls.add(server.url);
102
+ }
103
+ }
104
+ }
105
+ } catch {
106
+ // File doesn't exist or is invalid, use defaults
107
+ }
108
+ return this;
109
+ }
110
+
111
+ /**
112
+ * Save servers to directory file
113
+ */
114
+ async save(): Promise<void> {
115
+ const dir = path.dirname(this.directoryPath);
116
+ await fs.mkdir(dir, { recursive: true });
117
+ await fs.writeFile(this.directoryPath, JSON.stringify({
118
+ version: 1,
119
+ updated_at: new Date().toISOString(),
120
+ servers: this.servers
121
+ } as ServerDirectoryFile, null, 2));
122
+ }
123
+
124
+ /**
125
+ * Add a server to the directory
126
+ */
127
+ async addServer(server: ServerEntry): Promise<void> {
128
+ const existing = this.servers.find(s => s.url === server.url);
129
+ if (existing) {
130
+ Object.assign(existing, server);
131
+ } else {
132
+ this.servers.push(server);
133
+ }
134
+ await this.save();
135
+ }
136
+
137
+ /**
138
+ * Remove a server from the directory
139
+ */
140
+ async removeServer(url: string): Promise<void> {
141
+ this.servers = this.servers.filter(s => s.url !== url);
142
+ await this.save();
143
+ }
144
+
145
+ /**
146
+ * Check health of a single server
147
+ * @param server - Server object with url
148
+ * @returns Server with health status
149
+ */
150
+ async checkHealth(server: ServerEntry): Promise<ServerWithStatus> {
151
+ const wsUrl = server.url;
152
+ // Convert ws:// or wss:// to http:// or https://
153
+ const httpUrl = wsUrl
154
+ .replace('wss://', 'https://')
155
+ .replace('ws://', 'http://');
156
+
157
+ try {
158
+ const health = await this._fetchHealth(httpUrl + '/health');
159
+ return {
160
+ ...server,
161
+ status: 'online',
162
+ health,
163
+ checked_at: new Date().toISOString()
164
+ };
165
+ } catch (err) {
166
+ const error = err as Error & { code?: string };
167
+ return {
168
+ ...server,
169
+ status: 'offline',
170
+ error: error.message || error.code || 'Unknown error',
171
+ checked_at: new Date().toISOString()
172
+ };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Fetch health endpoint
178
+ */
179
+ private _fetchHealth(url: string): Promise<HealthData> {
180
+ return new Promise((resolve, reject) => {
181
+ const protocol = url.startsWith('https') ? https : http;
182
+ const req = protocol.get(url, { timeout: this.timeout }, (res) => {
183
+ let data = '';
184
+ res.on('data', (chunk: Buffer) => data += chunk);
185
+ res.on('end', () => {
186
+ if (res.statusCode === 200) {
187
+ try {
188
+ resolve(JSON.parse(data) as HealthData);
189
+ } catch {
190
+ reject(new Error('Invalid health response'));
191
+ }
192
+ } else {
193
+ reject(new Error(`HTTP ${res.statusCode}`));
194
+ }
195
+ });
196
+ });
197
+
198
+ req.on('error', reject);
199
+ req.on('timeout', () => {
200
+ req.destroy();
201
+ reject(new Error('Timeout'));
202
+ });
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Discover available servers (check health of all known servers)
208
+ * @param options
209
+ * @param options.onlineOnly - Only return online servers
210
+ * @returns List of servers with status
211
+ */
212
+ async discover(options: DiscoverOptions = {}): Promise<ServerWithStatus[]> {
213
+ const results = await Promise.all(
214
+ this.servers.map(server => this.checkHealth(server))
215
+ );
216
+
217
+ if (options.onlineOnly) {
218
+ return results.filter(s => s.status === 'online');
219
+ }
220
+
221
+ return results;
222
+ }
223
+
224
+ /**
225
+ * Get list of known servers without health check
226
+ */
227
+ list(): ServerEntry[] {
228
+ return [...this.servers];
229
+ }
230
+ }
231
+
232
+ export default ServerDirectory;