@agent-relay/acp-bridge 0.1.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.
@@ -0,0 +1,702 @@
1
+ /**
2
+ * ACP Agent Implementation
3
+ *
4
+ * Implements the ACP Agent interface to bridge relay agents to ACP clients.
5
+ */
6
+ import { randomUUID } from 'node:crypto';
7
+ import * as acp from '@agentclientprotocol/sdk';
8
+ import { RelayClient } from '@agent-relay/sdk';
9
+ /**
10
+ * ACP Agent that bridges to Agent Relay
11
+ */
12
+ export class RelayACPAgent {
13
+ config;
14
+ relayClient = null;
15
+ connection = null;
16
+ sessions = new Map();
17
+ messageBuffer = new Map();
18
+ constructor(config) {
19
+ this.config = config;
20
+ }
21
+ /**
22
+ * Start the ACP agent with stdio transport
23
+ */
24
+ async start() {
25
+ // Connect to relay daemon
26
+ const relayConfig = {
27
+ agentName: this.config.agentName,
28
+ program: '@agent-relay/acp-bridge',
29
+ cli: 'acp-bridge',
30
+ quiet: true,
31
+ };
32
+ if (this.config.socketPath) {
33
+ relayConfig.socketPath = this.config.socketPath;
34
+ }
35
+ this.relayClient = new RelayClient(relayConfig);
36
+ // Set up message handlers
37
+ this.relayClient.onMessage = (from, payload, messageId) => {
38
+ if (typeof payload.body !== 'string') {
39
+ return;
40
+ }
41
+ this.handleRelayMessage({
42
+ id: messageId,
43
+ from,
44
+ body: payload.body,
45
+ thread: payload.thread,
46
+ timestamp: Date.now(),
47
+ data: payload.data,
48
+ });
49
+ };
50
+ // Handle channel messages (e.g., #general)
51
+ this.relayClient.onChannelMessage = (from, channel, body) => {
52
+ this.debug('Received channel message:', from, channel, body.substring(0, 50));
53
+ // Route channel messages to all sessions
54
+ this.handleRelayMessage({
55
+ id: `channel-${Date.now()}`,
56
+ from: `${from} [${channel}]`,
57
+ body,
58
+ timestamp: Date.now(),
59
+ });
60
+ };
61
+ this.relayClient.onStateChange = (state) => {
62
+ this.debug('Relay client state:', state);
63
+ };
64
+ this.relayClient.onError = (error) => {
65
+ this.debug('Relay client error:', error);
66
+ };
67
+ try {
68
+ await this.relayClient.connect();
69
+ this.debug('Connected to relay daemon via SDK');
70
+ // Subscribe to #general channel to receive broadcast messages
71
+ this.relayClient.subscribe('#general');
72
+ this.debug('Subscribed to #general channel');
73
+ }
74
+ catch (err) {
75
+ this.debug('Failed to connect to relay daemon via SDK:', err);
76
+ // Continue anyway - we can still function without relay
77
+ }
78
+ // Create ACP connection over stdio using ndJsonStream
79
+ const readable = this.nodeToWebReadable(process.stdin);
80
+ const writable = this.nodeToWebWritable(process.stdout);
81
+ const stream = acp.ndJsonStream(writable, readable);
82
+ // Create connection with agent factory
83
+ this.connection = new acp.AgentSideConnection((conn) => {
84
+ // Store connection reference for later use
85
+ this.connection = conn;
86
+ return this;
87
+ }, stream);
88
+ this.debug('ACP agent started');
89
+ // Keep alive by waiting for connection to close
90
+ await this.connection.closed;
91
+ }
92
+ /**
93
+ * Stop the agent
94
+ */
95
+ async stop() {
96
+ this.relayClient?.destroy();
97
+ this.relayClient = null;
98
+ this.connection = null;
99
+ this.debug('ACP agent stopped');
100
+ }
101
+ // =========================================================================
102
+ // ACP Agent Interface Implementation
103
+ // =========================================================================
104
+ /**
105
+ * Initialize the agent connection
106
+ */
107
+ async initialize(_params) {
108
+ return {
109
+ protocolVersion: acp.PROTOCOL_VERSION,
110
+ agentCapabilities: {
111
+ loadSession: this.config.capabilities?.supportsSessionLoading ?? false,
112
+ },
113
+ };
114
+ }
115
+ /**
116
+ * Authenticate with the client (no auth required for relay)
117
+ */
118
+ async authenticate(_params) {
119
+ return {};
120
+ }
121
+ /**
122
+ * Create a new session
123
+ */
124
+ async newSession(_params) {
125
+ const sessionId = randomUUID();
126
+ const session = {
127
+ id: sessionId,
128
+ createdAt: new Date(),
129
+ messages: [],
130
+ isProcessing: false,
131
+ };
132
+ this.sessions.set(sessionId, session);
133
+ this.messageBuffer.set(sessionId, []);
134
+ this.debug('Created new session:', sessionId);
135
+ // Show quick help in the editor panel
136
+ await this.sendTextUpdate(sessionId, this.getHelpText());
137
+ return { sessionId };
138
+ }
139
+ /**
140
+ * Load an existing session (not supported)
141
+ */
142
+ async loadSession(_params) {
143
+ throw new Error('Session loading not supported');
144
+ }
145
+ /**
146
+ * Set session mode (optional)
147
+ */
148
+ async setSessionMode(_params) {
149
+ // Mode changes not implemented
150
+ return {};
151
+ }
152
+ /**
153
+ * Handle a prompt from the client
154
+ */
155
+ async prompt(params) {
156
+ const session = this.sessions.get(params.sessionId);
157
+ if (!session) {
158
+ throw new Error(`Session not found: ${params.sessionId}`);
159
+ }
160
+ if (session.isProcessing) {
161
+ throw new Error('Session is already processing a prompt');
162
+ }
163
+ session.isProcessing = true;
164
+ session.abortController = new AbortController();
165
+ try {
166
+ // Extract text content from the prompt
167
+ const userMessage = this.extractTextContent(params.prompt);
168
+ // Add to session history
169
+ session.messages.push({
170
+ role: 'user',
171
+ content: userMessage,
172
+ timestamp: new Date(),
173
+ });
174
+ // Handle agent-relay CLI-style commands locally before broadcasting
175
+ const handled = await this.tryHandleCliCommand(userMessage, params.sessionId);
176
+ if (handled) {
177
+ return { stopReason: 'end_turn' };
178
+ }
179
+ // Send to relay agents
180
+ const result = await this.bridgeToRelay(session, userMessage, params.sessionId, session.abortController.signal);
181
+ if (result.stopReason === 'cancelled') {
182
+ return { stopReason: 'cancelled' };
183
+ }
184
+ return { stopReason: 'end_turn' };
185
+ }
186
+ finally {
187
+ session.isProcessing = false;
188
+ session.abortController = undefined;
189
+ }
190
+ }
191
+ /**
192
+ * Cancel the current operation
193
+ */
194
+ async cancel(params) {
195
+ const session = this.sessions.get(params.sessionId);
196
+ if (session?.abortController) {
197
+ session.abortController.abort();
198
+ }
199
+ }
200
+ // =========================================================================
201
+ // Relay Bridge Logic
202
+ // =========================================================================
203
+ /**
204
+ * Parse @mentions from a message.
205
+ * Returns { targets: string[], message: string } where targets are agent names
206
+ * and message is the text with @mentions removed.
207
+ *
208
+ * Examples:
209
+ * "@Worker hello" -> { targets: ["Worker"], message: "hello" }
210
+ * "@Worker @Reviewer review this" -> { targets: ["Worker", "Reviewer"], message: "review this" }
211
+ * "hello everyone" -> { targets: [], message: "hello everyone" }
212
+ */
213
+ parseAtMentions(text) {
214
+ const mentionRegex = /@(\w+)/g;
215
+ const targets = [];
216
+ let match;
217
+ while ((match = mentionRegex.exec(text)) !== null) {
218
+ targets.push(match[1]);
219
+ }
220
+ // Remove @mentions from message
221
+ const message = text.replace(/@\w+\s*/g, '').trim();
222
+ return { targets, message: message || text };
223
+ }
224
+ /**
225
+ * Bridge a user prompt to relay agents and collect responses
226
+ */
227
+ async bridgeToRelay(session, userMessage, sessionId, signal) {
228
+ if (!this.connection) {
229
+ return {
230
+ success: false,
231
+ stopReason: 'error',
232
+ responses: [],
233
+ error: 'No ACP connection',
234
+ };
235
+ }
236
+ if (!this.relayClient || this.relayClient.state !== 'READY') {
237
+ // If not connected to relay, return a helpful message
238
+ await this.connection.sessionUpdate({
239
+ sessionId,
240
+ update: {
241
+ sessionUpdate: 'agent_message_chunk',
242
+ content: {
243
+ type: 'text',
244
+ text: 'Agent Relay daemon is not connected. Please ensure the relay daemon is running.',
245
+ },
246
+ },
247
+ });
248
+ return {
249
+ success: false,
250
+ stopReason: 'end_turn',
251
+ responses: [],
252
+ };
253
+ }
254
+ const responses = [];
255
+ // Clear buffer
256
+ this.messageBuffer.set(session.id, []);
257
+ // Parse @mentions to target specific agents
258
+ const { targets, message: cleanMessage } = this.parseAtMentions(userMessage);
259
+ const hasTargets = targets.length > 0;
260
+ // Send "thinking" indicator with target info
261
+ const targetInfo = hasTargets
262
+ ? `Sending to ${targets.map(t => `@${t}`).join(', ')}...\n\n`
263
+ : 'Broadcasting to all agents...\n\n';
264
+ await this.connection.sessionUpdate({
265
+ sessionId,
266
+ update: {
267
+ sessionUpdate: 'agent_message_chunk',
268
+ content: {
269
+ type: 'text',
270
+ text: targetInfo,
271
+ },
272
+ },
273
+ });
274
+ // Send to specific agents or broadcast
275
+ let sent = false;
276
+ if (hasTargets) {
277
+ // Send to each mentioned agent
278
+ for (const target of targets) {
279
+ const result = this.relayClient.sendMessage(target, cleanMessage, 'message', undefined, session.id);
280
+ if (result)
281
+ sent = true;
282
+ }
283
+ }
284
+ else {
285
+ // Broadcast to all agents
286
+ sent = this.relayClient.sendMessage('*', userMessage, 'message', undefined, session.id);
287
+ }
288
+ if (!sent) {
289
+ await this.connection.sessionUpdate({
290
+ sessionId,
291
+ update: {
292
+ sessionUpdate: 'agent_message_chunk',
293
+ content: {
294
+ type: 'text',
295
+ text: 'Failed to send message to relay agents. Please check the relay daemon connection.',
296
+ },
297
+ },
298
+ });
299
+ return {
300
+ success: false,
301
+ stopReason: 'error',
302
+ responses,
303
+ };
304
+ }
305
+ // Wait for responses with timeout
306
+ const responseTimeout = 30000; // 30 seconds
307
+ const startTime = Date.now();
308
+ while (Date.now() - startTime < responseTimeout) {
309
+ if (signal.aborted) {
310
+ return {
311
+ success: false,
312
+ stopReason: 'cancelled',
313
+ responses,
314
+ };
315
+ }
316
+ // Check for new messages in buffer
317
+ const newMessages = this.messageBuffer.get(session.id) || [];
318
+ if (newMessages.length > 0) {
319
+ responses.push(...newMessages);
320
+ this.messageBuffer.set(session.id, []);
321
+ // Stream each response as it arrives
322
+ for (const msg of newMessages) {
323
+ await this.connection.sessionUpdate({
324
+ sessionId,
325
+ update: {
326
+ sessionUpdate: 'agent_message_chunk',
327
+ content: {
328
+ type: 'text',
329
+ text: `**${msg.from}**: ${msg.body}\n\n`,
330
+ },
331
+ },
332
+ });
333
+ // Add to session history
334
+ session.messages.push({
335
+ role: 'assistant',
336
+ content: msg.body,
337
+ timestamp: new Date(msg.timestamp),
338
+ fromAgent: msg.from,
339
+ });
340
+ }
341
+ }
342
+ // Small delay to prevent busy waiting
343
+ await this.sleep(100);
344
+ // If we have responses and nothing new for 2 seconds, consider it done
345
+ if (responses.length > 0) {
346
+ const lastMessage = responses[responses.length - 1];
347
+ if (Date.now() - lastMessage.timestamp > 2000) {
348
+ break;
349
+ }
350
+ }
351
+ }
352
+ return {
353
+ success: true,
354
+ stopReason: 'end_turn',
355
+ responses,
356
+ };
357
+ }
358
+ /**
359
+ * Handle incoming relay messages
360
+ */
361
+ handleRelayMessage(message) {
362
+ this.debug('Received relay message:', message.from, message.body.substring(0, 50));
363
+ // Check for system messages (crash notifications, etc.)
364
+ if (message.data?.isSystemMessage) {
365
+ this.handleSystemMessage(message);
366
+ return;
367
+ }
368
+ // Route to appropriate session based on thread
369
+ if (message.thread) {
370
+ const buffer = this.messageBuffer.get(message.thread);
371
+ if (buffer) {
372
+ buffer.push(message);
373
+ return;
374
+ }
375
+ }
376
+ // If no specific session, add to all active sessions
377
+ for (const [sessionId, session] of this.sessions) {
378
+ if (session.isProcessing) {
379
+ const buffer = this.messageBuffer.get(sessionId) || [];
380
+ buffer.push(message);
381
+ this.messageBuffer.set(sessionId, buffer);
382
+ }
383
+ }
384
+ }
385
+ /**
386
+ * Handle system messages (crash notifications, etc.)
387
+ * These are displayed to all sessions regardless of processing state.
388
+ */
389
+ handleSystemMessage(message) {
390
+ const data = message.data || {};
391
+ // Format crash notifications nicely
392
+ if (data.crashType) {
393
+ const agentName = data.agentName || message.from || 'Unknown agent';
394
+ const signal = data.signal ? ` (${data.signal})` : '';
395
+ const exitCode = data.exitCode !== undefined ? ` [exit code: ${data.exitCode}]` : '';
396
+ const crashNotification = [
397
+ '',
398
+ `⚠️ **Agent Crashed**: \`${agentName}\`${signal}${exitCode}`,
399
+ '',
400
+ message.body,
401
+ '',
402
+ ].join('\n');
403
+ // Send to all sessions (not just processing ones)
404
+ this.broadcastToAllSessions(crashNotification);
405
+ }
406
+ else {
407
+ // Generic system message
408
+ this.broadcastToAllSessions(`**System**: ${message.body}`);
409
+ }
410
+ }
411
+ /**
412
+ * Broadcast a message to all active sessions.
413
+ */
414
+ broadcastToAllSessions(text) {
415
+ for (const [sessionId] of this.sessions) {
416
+ this.sendTextUpdate(sessionId, text).catch((err) => {
417
+ this.debug('Failed to send broadcast to session:', sessionId, err);
418
+ });
419
+ }
420
+ }
421
+ // =========================================================================
422
+ // CLI Command Handling (Zed Agent Panel)
423
+ // =========================================================================
424
+ /**
425
+ * Parse and handle agent-relay CLI-style commands coming from the editor.
426
+ */
427
+ async tryHandleCliCommand(userMessage, sessionId) {
428
+ const tokens = this.parseCliArgs(userMessage);
429
+ if (tokens.length === 0) {
430
+ return false;
431
+ }
432
+ let command = tokens[0];
433
+ let args = tokens.slice(1);
434
+ // Support "agent-relay ..." and "relay ..." prefixes
435
+ if (command === 'agent-relay' || command === 'relay') {
436
+ if (args.length === 0)
437
+ return false;
438
+ command = args[0];
439
+ args = args.slice(1);
440
+ }
441
+ else if (command === 'create' && args[0] === 'agent') {
442
+ command = 'spawn';
443
+ args = args.slice(1);
444
+ }
445
+ switch (command) {
446
+ case 'spawn':
447
+ case 'create-agent':
448
+ return this.handleSpawnCommand(args, sessionId);
449
+ case 'release':
450
+ return this.handleReleaseCommand(args, sessionId);
451
+ case 'agents':
452
+ case 'who':
453
+ return this.handleListAgentsCommand(sessionId);
454
+ case 'status':
455
+ return this.handleStatusCommand(sessionId);
456
+ case 'help':
457
+ await this.sendTextUpdate(sessionId, this.getHelpText());
458
+ return true;
459
+ default:
460
+ return false;
461
+ }
462
+ }
463
+ async handleSpawnCommand(args, sessionId) {
464
+ const [name, cli, ...taskParts] = args;
465
+ if (!name || !cli) {
466
+ await this.sendTextUpdate(sessionId, 'Usage: agent-relay spawn <name> <cli> "<task>"');
467
+ return true;
468
+ }
469
+ if (!this.relayClient || this.relayClient.state !== 'READY') {
470
+ await this.sendTextUpdate(sessionId, 'Relay daemon is not connected (cannot spawn).');
471
+ return true;
472
+ }
473
+ const task = taskParts.join(' ').trim() || undefined;
474
+ await this.sendTextUpdate(sessionId, `Spawning ${name} (${cli})${task ? `: ${task}` : ''}`);
475
+ try {
476
+ const result = await this.relayClient.spawn({
477
+ name,
478
+ cli,
479
+ task,
480
+ waitForReady: true,
481
+ });
482
+ if (result.success) {
483
+ const readyText = result.ready ? ' (ready)' : '';
484
+ await this.sendTextUpdate(sessionId, `Spawned ${name}${readyText}.`);
485
+ }
486
+ else {
487
+ await this.sendTextUpdate(sessionId, `Failed to spawn ${name}: ${result.error || 'unknown error'}`);
488
+ }
489
+ }
490
+ catch (err) {
491
+ await this.sendTextUpdate(sessionId, `Spawn error for ${name}: ${err.message}`);
492
+ }
493
+ return true;
494
+ }
495
+ async handleReleaseCommand(args, sessionId) {
496
+ const [name] = args;
497
+ if (!name) {
498
+ await this.sendTextUpdate(sessionId, 'Usage: agent-relay release <name>');
499
+ return true;
500
+ }
501
+ if (!this.relayClient || this.relayClient.state !== 'READY') {
502
+ await this.sendTextUpdate(sessionId, 'Relay daemon is not connected (cannot release).');
503
+ return true;
504
+ }
505
+ await this.sendTextUpdate(sessionId, `Releasing ${name}...`);
506
+ try {
507
+ const result = await this.relayClient.release(name);
508
+ if (result.success) {
509
+ await this.sendTextUpdate(sessionId, `Released ${name}.`);
510
+ }
511
+ else {
512
+ await this.sendTextUpdate(sessionId, `Failed to release ${name}: ${result.error || 'unknown error'}`);
513
+ }
514
+ }
515
+ catch (err) {
516
+ await this.sendTextUpdate(sessionId, `Release error for ${name}: ${err.message}`);
517
+ }
518
+ return true;
519
+ }
520
+ async handleListAgentsCommand(sessionId) {
521
+ if (!this.relayClient || this.relayClient.state !== 'READY') {
522
+ await this.sendTextUpdate(sessionId, 'Relay daemon is not connected (cannot list agents).');
523
+ return true;
524
+ }
525
+ try {
526
+ const agents = await this.relayClient.listConnectedAgents();
527
+ if (!agents.length) {
528
+ await this.sendTextUpdate(sessionId, 'No agents are currently connected.');
529
+ }
530
+ else {
531
+ const lines = agents.map((agent) => `- ${agent.name}${agent.cli ? ` (${agent.cli})` : ''}`);
532
+ await this.sendTextUpdate(sessionId, ['Connected agents:', ...lines].join('\n'));
533
+ }
534
+ }
535
+ catch (err) {
536
+ await this.sendTextUpdate(sessionId, `Failed to list agents: ${err.message}`);
537
+ }
538
+ return true;
539
+ }
540
+ async handleStatusCommand(sessionId) {
541
+ const lines = ['Agent Relay Status', ''];
542
+ if (!this.relayClient) {
543
+ lines.push('Relay client: Not initialized');
544
+ await this.sendTextUpdate(sessionId, lines.join('\n'));
545
+ return true;
546
+ }
547
+ const state = this.relayClient.state;
548
+ const isConnected = state === 'READY';
549
+ lines.push(`Connection: ${isConnected ? 'Connected' : 'Disconnected'}`);
550
+ lines.push(`State: ${state}`);
551
+ lines.push(`Agent name: ${this.config.agentName}`);
552
+ if (isConnected) {
553
+ // Try to get connected agents count
554
+ try {
555
+ const agents = await this.relayClient.listConnectedAgents();
556
+ lines.push(`Connected agents: ${agents.length}`);
557
+ }
558
+ catch {
559
+ // Ignore errors when listing agents
560
+ }
561
+ }
562
+ await this.sendTextUpdate(sessionId, lines.join('\n'));
563
+ return true;
564
+ }
565
+ async sendTextUpdate(sessionId, text) {
566
+ if (!this.connection)
567
+ return;
568
+ await this.connection.sessionUpdate({
569
+ sessionId,
570
+ update: {
571
+ sessionUpdate: 'agent_message_chunk',
572
+ content: {
573
+ type: 'text',
574
+ text,
575
+ },
576
+ },
577
+ });
578
+ }
579
+ parseCliArgs(input) {
580
+ const args = [];
581
+ let current = '';
582
+ let inQuote = null;
583
+ let escape = false;
584
+ for (const char of input.trim()) {
585
+ if (escape) {
586
+ current += char;
587
+ escape = false;
588
+ continue;
589
+ }
590
+ if (char === '\\') {
591
+ escape = true;
592
+ continue;
593
+ }
594
+ if (inQuote) {
595
+ if (char === inQuote) {
596
+ inQuote = null;
597
+ }
598
+ else {
599
+ current += char;
600
+ }
601
+ continue;
602
+ }
603
+ if (char === '"' || char === "'") {
604
+ inQuote = char;
605
+ continue;
606
+ }
607
+ if (/\s/.test(char)) {
608
+ if (current) {
609
+ args.push(current);
610
+ current = '';
611
+ }
612
+ continue;
613
+ }
614
+ current += char;
615
+ }
616
+ if (current) {
617
+ args.push(current);
618
+ }
619
+ return args;
620
+ }
621
+ getHelpText() {
622
+ return [
623
+ 'Agent Relay (Zed)',
624
+ '',
625
+ 'Commands:',
626
+ '- agent-relay spawn <name> <cli> "task"',
627
+ '- agent-relay release <name>',
628
+ '- agent-relay agents',
629
+ '- agent-relay status',
630
+ '- agent-relay help',
631
+ '',
632
+ 'Other messages are broadcast to connected agents.',
633
+ ].join('\n');
634
+ }
635
+ // =========================================================================
636
+ // Utility Methods
637
+ // =========================================================================
638
+ /**
639
+ * Extract text content from ACP content blocks
640
+ */
641
+ extractTextContent(content) {
642
+ return content
643
+ .filter((block) => block.type === 'text')
644
+ .map((block) => block.text)
645
+ .join('\n');
646
+ }
647
+ /**
648
+ * Convert Node.js readable stream to Web ReadableStream
649
+ */
650
+ nodeToWebReadable(nodeStream) {
651
+ return new ReadableStream({
652
+ start(controller) {
653
+ nodeStream.on('data', (chunk) => {
654
+ controller.enqueue(new Uint8Array(chunk));
655
+ });
656
+ nodeStream.on('end', () => {
657
+ controller.close();
658
+ });
659
+ nodeStream.on('error', (err) => {
660
+ controller.error(err);
661
+ });
662
+ },
663
+ });
664
+ }
665
+ /**
666
+ * Convert Node.js writable stream to Web WritableStream
667
+ */
668
+ nodeToWebWritable(nodeStream) {
669
+ return new WritableStream({
670
+ write(chunk) {
671
+ return new Promise((resolve, reject) => {
672
+ nodeStream.write(Buffer.from(chunk), (err) => {
673
+ if (err)
674
+ reject(err);
675
+ else
676
+ resolve();
677
+ });
678
+ });
679
+ },
680
+ close() {
681
+ return new Promise((resolve) => {
682
+ nodeStream.end(() => resolve());
683
+ });
684
+ },
685
+ });
686
+ }
687
+ /**
688
+ * Sleep utility
689
+ */
690
+ sleep(ms) {
691
+ return new Promise((resolve) => setTimeout(resolve, ms));
692
+ }
693
+ /**
694
+ * Debug logging
695
+ */
696
+ debug(...args) {
697
+ if (this.config.debug) {
698
+ console.error('[RelayACPAgent]', ...args);
699
+ }
700
+ }
701
+ }
702
+ //# sourceMappingURL=acp-agent.js.map