agentgate 0.1.9 → 0.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.
package/README.md CHANGED
@@ -135,9 +135,29 @@ agentgate can notify your agent when queue items are completed, failed, or rejec
135
135
 
136
136
  Compatible with OpenClaw's `/hooks/wake` endpoint. See [OpenClaw webhook docs](https://docs.openclaw.ai/automation/webhook).
137
137
 
138
- ## API Key Management
138
+ ## Inter-Agent Messaging
139
139
 
140
- Create and manage API keys for your agents in the admin UI at `/ui/keys`.
140
+ Agents can communicate with each other through agentgate, with optional human oversight.
141
+
142
+ šŸ“– **[See full documentation](docs/inter-agent-messaging.md)**
143
+
144
+ **Quick overview:**
145
+ - Three modes: **Off**, **Supervised** (human approval), **Open** (immediate delivery)
146
+ - Configure agent webhooks in Admin UI under **API Keys > Configure**
147
+ - Endpoints: `/api/agents/messageable`, `/api/agents/message`, `/api/agents/messages`, `/api/agents/status`
148
+
149
+ ## Agent Registry
150
+
151
+ Manage your agents in the admin UI at `/ui/keys`. Each agent has:
152
+
153
+ - **Name** - Unique identifier (case-insensitive)
154
+ - **API Key** - Bearer token for agent → agentgate authentication (shown once at creation)
155
+ - **Webhook URL** (optional) - Endpoint for agentgate → agent notifications
156
+ - **Webhook Token** (optional) - Bearer token for webhook authentication
157
+
158
+ When an agent's webhook is configured, agentgate will POST notifications for:
159
+ - Queue item status changes (approved/rejected/completed/failed)
160
+ - Inter-agent message delivery or rejection
141
161
 
142
162
 
143
163
  ## Usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgate",
3
- "version": "0.1.9",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "API gateway for AI agents with human-in-the-loop write approval",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -2,7 +2,7 @@ import express from 'express';
2
2
  import cookieParser from 'cookie-parser';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
- import { validateApiKey, getAccountsByService, getCookieSecret, getSetting } from './lib/db.js';
5
+ import { validateApiKey, getAccountsByService, getCookieSecret, getSetting, getMessagingMode } from './lib/db.js';
6
6
  import { connectHsync } from './lib/hsyncManager.js';
7
7
  import githubRoutes, { serviceInfo as githubInfo } from './routes/github.js';
8
8
  import blueskyRoutes, { serviceInfo as blueskyInfo } from './routes/bluesky.js';
@@ -14,6 +14,7 @@ import youtubeRoutes, { serviceInfo as youtubeInfo } from './routes/youtube.js';
14
14
  import jiraRoutes, { serviceInfo as jiraInfo } from './routes/jira.js';
15
15
  import fitbitRoutes, { serviceInfo as fitbitInfo } from './routes/fitbit.js';
16
16
  import queueRoutes from './routes/queue.js';
17
+ import agentsRoutes from './routes/agents.js';
17
18
  import uiRoutes from './routes/ui.js';
18
19
 
19
20
  // Aggregate service metadata from all routes
@@ -80,6 +81,13 @@ app.use('/api/fitbit', apiKeyAuth, readOnlyEnforce, fitbitRoutes);
80
81
  // Pattern: /api/queue/{service}/{accountName}/submit
81
82
  app.use('/api/queue', apiKeyAuth, queueRoutes);
82
83
 
84
+ // Agent messaging routes - require auth, allow POST for sending messages
85
+ // Includes apiKeyName in req for sender identification
86
+ app.use('/api/agents', apiKeyAuth, (req, res, next) => {
87
+ req.apiKeyName = req.apiKeyInfo.name;
88
+ next();
89
+ }, agentsRoutes);
90
+
83
91
  // UI routes - no API key needed (local admin access)
84
92
  app.use('/ui', uiRoutes);
85
93
 
@@ -233,7 +241,61 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
233
241
  queryParams: {
234
242
  base_url: 'Override the base URL in the generated skill (optional)'
235
243
  }
236
- }
244
+ },
245
+ agentMessaging: (() => {
246
+ const mode = getMessagingMode();
247
+ return {
248
+ enabled: mode !== 'off',
249
+ mode,
250
+ description: mode === 'off'
251
+ ? 'Agent-to-agent messaging is disabled. Admin can enable it in the agentgate UI.'
252
+ : mode === 'supervised'
253
+ ? 'Agents can message each other. Messages require human approval before delivery.'
254
+ : 'Agents can message each other freely without approval.',
255
+ endpoints: {
256
+ sendMessage: {
257
+ method: 'POST',
258
+ path: '/api/agents/message',
259
+ body: { to: 'recipient_agent_name', message: 'Your message content' },
260
+ response: mode === 'supervised'
261
+ ? '{ id, status: "pending", message: "Message queued for human approval" }'
262
+ : '{ id, status: "delivered", message: "Message delivered" }'
263
+ },
264
+ getMessages: {
265
+ method: 'GET',
266
+ path: '/api/agents/messages',
267
+ queryParams: { unread: 'true (optional) - only return unread messages' },
268
+ response: '{ mode, messages: [{ id, from, message, created_at, read }, ...] }'
269
+ },
270
+ markRead: {
271
+ method: 'POST',
272
+ path: '/api/agents/messages/:id/read',
273
+ response: '{ success: true }'
274
+ },
275
+ status: {
276
+ method: 'GET',
277
+ path: '/api/agents/status',
278
+ response: '{ mode, enabled, unread_count }'
279
+ },
280
+ discoverAgents: {
281
+ method: 'GET',
282
+ path: '/api/agents/messageable',
283
+ description: 'Discover which agents you can message',
284
+ response: '{ mode, agents: [{ name }, ...] }'
285
+ }
286
+ },
287
+ modes: {
288
+ off: 'Messaging disabled - agents cannot communicate',
289
+ supervised: 'Messages require human approval before delivery',
290
+ open: 'Messages delivered immediately without approval'
291
+ },
292
+ notes: [
293
+ 'Agent names are case-insensitive (e.g., "WorkBot" and "workbot" are the same)',
294
+ 'Agents cannot message themselves',
295
+ 'Maximum message length is 10KB'
296
+ ]
297
+ };
298
+ })()
237
299
  });
238
300
  });
239
301
 
@@ -0,0 +1,150 @@
1
+ // Agent notification delivery - sends webhooks to agent gateways
2
+ import { getApiKeyByName, updateQueueNotification } from './db.js';
3
+
4
+ // Send a notification to an agent's webhook
5
+ export async function notifyAgent(agentName, payload) {
6
+ const agent = getApiKeyByName(agentName);
7
+
8
+ if (!agent) {
9
+ return { success: false, error: `Agent "${agentName}" not found` };
10
+ }
11
+
12
+ if (!agent.webhook_url) {
13
+ return { success: false, error: `Agent "${agentName}" has no webhook configured` };
14
+ }
15
+
16
+ try {
17
+ const headers = {
18
+ 'Content-Type': 'application/json'
19
+ };
20
+
21
+ if (agent.webhook_token) {
22
+ headers['Authorization'] = `Bearer ${agent.webhook_token}`;
23
+ }
24
+
25
+ const response = await fetch(agent.webhook_url, {
26
+ method: 'POST',
27
+ headers,
28
+ body: JSON.stringify(payload)
29
+ });
30
+
31
+ if (response.ok) {
32
+ return { success: true };
33
+ } else {
34
+ const text = await response.text().catch(() => '');
35
+ return { success: false, error: `HTTP ${response.status}: ${text.substring(0, 200)}` };
36
+ }
37
+ } catch (err) {
38
+ return { success: false, error: err.message };
39
+ }
40
+ }
41
+
42
+ // Notify agent about a queue item status change
43
+ export async function notifyAgentQueueStatus(entry) {
44
+ const agentName = entry.submitted_by;
45
+ if (!agentName) {
46
+ return { success: false, error: 'No submitter on queue entry' };
47
+ }
48
+
49
+ const agent = getApiKeyByName(agentName);
50
+ if (!agent?.webhook_url) {
51
+ // Agent doesn't have webhook configured - that's ok, just skip
52
+ return { success: false, error: 'No webhook configured' };
53
+ }
54
+
55
+ const statusEmoji = {
56
+ completed: 'āœ…',
57
+ failed: 'āŒ',
58
+ rejected: '🚫'
59
+ };
60
+
61
+ const payload = {
62
+ type: 'queue_status',
63
+ entry: {
64
+ id: entry.id,
65
+ service: entry.service,
66
+ account_name: entry.account_name,
67
+ status: entry.status,
68
+ comment: entry.comment,
69
+ rejection_reason: entry.rejection_reason,
70
+ results: entry.results
71
+ },
72
+ // Also include a human-readable message for Clawdbot-style gateways
73
+ text: `${statusEmoji[entry.status] || 'šŸ“‹'} [agentgate] Queue #${entry.id.substring(0, 8)} ${entry.status}\n→ ${entry.service}/${entry.account_name}${entry.rejection_reason ? `\nReason: ${entry.rejection_reason}` : ''}${entry.comment ? `\nOriginal: "${entry.comment.substring(0, 100)}"` : ''}`,
74
+ mode: 'now'
75
+ };
76
+
77
+ const result = await notifyAgent(agentName, payload);
78
+
79
+ // Update notification status in db
80
+ if (result.success) {
81
+ updateQueueNotification(entry.id, true);
82
+ } else {
83
+ updateQueueNotification(entry.id, false, result.error);
84
+ }
85
+
86
+ return result;
87
+ }
88
+
89
+ // Notify agent about a new message (delivered to recipient)
90
+ export async function notifyAgentMessage(message) {
91
+ const agentName = message.to_agent;
92
+
93
+ const agent = getApiKeyByName(agentName);
94
+ if (!agent?.webhook_url) {
95
+ return { success: false, error: 'No webhook configured' };
96
+ }
97
+
98
+ const payload = {
99
+ type: 'agent_message',
100
+ message: {
101
+ id: message.id,
102
+ from: message.from_agent,
103
+ message: message.message,
104
+ created_at: message.created_at,
105
+ delivered_at: message.delivered_at
106
+ },
107
+ // Human-readable for Clawdbot-style gateways
108
+ text: `šŸ’¬ [agentgate] Message from ${message.from_agent}:\n${message.message.substring(0, 500)}`,
109
+ mode: 'now'
110
+ };
111
+
112
+ return notifyAgent(agentName, payload);
113
+ }
114
+
115
+ // Notify sender that their message was rejected
116
+ export async function notifyMessageRejected(message) {
117
+ const agentName = message.from_agent;
118
+
119
+ const agent = getApiKeyByName(agentName);
120
+ if (!agent?.webhook_url) {
121
+ return { success: false, error: 'No webhook configured' };
122
+ }
123
+
124
+ const payload = {
125
+ type: 'message_rejected',
126
+ message: {
127
+ id: message.id,
128
+ to: message.to_agent,
129
+ message: message.message,
130
+ rejection_reason: message.rejection_reason,
131
+ created_at: message.created_at,
132
+ rejected_at: message.reviewed_at
133
+ },
134
+ // Human-readable for Clawdbot-style gateways
135
+ text: `🚫 [agentgate] Message to ${message.to_agent} was rejected${message.rejection_reason ? `\nReason: ${message.rejection_reason}` : ''}\nOriginal: "${message.message.substring(0, 200)}"`,
136
+ mode: 'now'
137
+ };
138
+
139
+ return notifyAgent(agentName, payload);
140
+ }
141
+
142
+ // Batch notify multiple agents about their messages
143
+ export async function notifyAgentMessagesBatch(messages) {
144
+ const results = [];
145
+ for (const msg of messages) {
146
+ const result = await notifyAgentMessage(msg);
147
+ results.push({ messageId: msg.id, ...result });
148
+ }
149
+ return results;
150
+ }
package/src/lib/db.js CHANGED
@@ -47,6 +47,22 @@ db.exec(`
47
47
  notified_at TEXT,
48
48
  notify_error TEXT
49
49
  );
50
+
51
+ CREATE TABLE IF NOT EXISTS agent_messages (
52
+ id TEXT PRIMARY KEY,
53
+ from_agent TEXT NOT NULL,
54
+ to_agent TEXT NOT NULL,
55
+ message TEXT NOT NULL,
56
+ status TEXT NOT NULL DEFAULT 'pending',
57
+ rejection_reason TEXT,
58
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
59
+ reviewed_at TEXT,
60
+ delivered_at TEXT,
61
+ read_at TEXT
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_agent_messages_recipient
65
+ ON agent_messages(to_agent, status);
50
66
  `);
51
67
 
52
68
  // Migrate write_queue table to add notification columns
@@ -71,19 +87,23 @@ try {
71
87
  }
72
88
 
73
89
  // Initialize api_keys table with migration support for old schema
74
- // Old schema had: id, name, key, created_at
75
- // New schema has: id, name, key_prefix, key_hash, created_at
90
+ // Schema evolution:
91
+ // v1: id, name, key, created_at
92
+ // v2: id, name, key_prefix, key_hash, created_at
93
+ // v3: + webhook_url, webhook_token (for agent configurations)
76
94
  try {
77
95
  const tableInfo = db.prepare('PRAGMA table_info(api_keys)').all();
78
96
 
79
97
  if (tableInfo.length === 0) {
80
- // Table doesn't exist, create with new schema
98
+ // Table doesn't exist, create with latest schema
81
99
  db.exec(`
82
100
  CREATE TABLE api_keys (
83
101
  id TEXT PRIMARY KEY,
84
- name TEXT NOT NULL,
102
+ name TEXT NOT NULL UNIQUE,
85
103
  key_prefix TEXT NOT NULL,
86
104
  key_hash TEXT NOT NULL,
105
+ webhook_url TEXT,
106
+ webhook_token TEXT,
87
107
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
88
108
  );
89
109
  `);
@@ -99,22 +119,46 @@ try {
99
119
  DROP TABLE api_keys;
100
120
  CREATE TABLE api_keys (
101
121
  id TEXT PRIMARY KEY,
102
- name TEXT NOT NULL,
122
+ name TEXT NOT NULL UNIQUE,
103
123
  key_prefix TEXT NOT NULL,
104
124
  key_hash TEXT NOT NULL,
125
+ webhook_url TEXT,
126
+ webhook_token TEXT,
105
127
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
106
128
  );
107
129
  `);
108
130
  console.log('Migration complete.');
131
+ } else {
132
+ // Check if we need to add webhook columns (v2 -> v3 migration)
133
+ const hasWebhookUrl = tableInfo.some(col => col.name === 'webhook_url');
134
+ if (!hasWebhookUrl) {
135
+ console.log('Adding webhook columns to api_keys table...');
136
+ db.exec(`
137
+ ALTER TABLE api_keys ADD COLUMN webhook_url TEXT;
138
+ ALTER TABLE api_keys ADD COLUMN webhook_token TEXT;
139
+ `);
140
+ console.log('Webhook columns added.');
141
+ }
109
142
  }
110
- // else: table exists with new schema, nothing to do
111
143
  }
112
144
  } catch (err) {
113
145
  console.error('Error initializing api_keys table:', err.message);
114
146
  }
115
147
 
116
148
  // API Keys
149
+
150
+ // Check if an agent name already exists (case-insensitive)
151
+ export function agentNameExists(name) {
152
+ const result = db.prepare('SELECT id FROM api_keys WHERE LOWER(name) = LOWER(?)').get(name);
153
+ return !!result;
154
+ }
155
+
117
156
  export async function createApiKey(name) {
157
+ // Check for duplicate names (case-insensitive)
158
+ if (agentNameExists(name)) {
159
+ throw new Error(`An agent with name "${name}" already exists (names are case-insensitive)`);
160
+ }
161
+
118
162
  const id = nanoid();
119
163
  const key = `rms_${nanoid(32)}`;
120
164
  const keyPrefix = key.substring(0, 8) + '...' + key.substring(key.length - 4);
@@ -124,20 +168,33 @@ export async function createApiKey(name) {
124
168
  }
125
169
 
126
170
  export function listApiKeys() {
127
- return db.prepare('SELECT id, name, key_prefix, created_at FROM api_keys').all();
171
+ return db.prepare('SELECT id, name, key_prefix, webhook_url, webhook_token, created_at FROM api_keys').all();
172
+ }
173
+
174
+ export function getApiKeyByName(name) {
175
+ // Case-insensitive lookup
176
+ return db.prepare('SELECT id, name, key_prefix, webhook_url, webhook_token, created_at FROM api_keys WHERE LOWER(name) = LOWER(?)').get(name);
177
+ }
178
+
179
+ export function getApiKeyById(id) {
180
+ return db.prepare('SELECT id, name, key_prefix, webhook_url, webhook_token, created_at FROM api_keys WHERE id = ?').get(id);
128
181
  }
129
182
 
130
183
  export function deleteApiKey(id) {
131
184
  return db.prepare('DELETE FROM api_keys WHERE id = ?').run(id);
132
185
  }
133
186
 
187
+ export function updateAgentWebhook(id, webhookUrl, webhookToken) {
188
+ return db.prepare('UPDATE api_keys SET webhook_url = ?, webhook_token = ? WHERE id = ?').run(webhookUrl || null, webhookToken || null, id);
189
+ }
190
+
134
191
  export async function validateApiKey(key) {
135
192
  // Must check all keys since we can't look up by hash directly
136
193
  const allKeys = db.prepare('SELECT * FROM api_keys').all();
137
194
  for (const row of allKeys) {
138
195
  const match = await bcrypt.compare(key, row.key_hash);
139
196
  if (match) {
140
- return { id: row.id, name: row.name };
197
+ return { id: row.id, name: row.name, webhookUrl: row.webhook_url, webhookToken: row.webhook_token };
141
198
  }
142
199
  }
143
200
  return null;
@@ -382,4 +439,124 @@ export function listQueueEntriesBySubmitter(submittedBy, service = null, account
382
439
  return db.prepare(sql).all(params);
383
440
  }
384
441
 
442
+ // Agent Messaging
443
+
444
+ // Get messaging mode: 'off', 'supervised', 'open'
445
+ export function getMessagingMode() {
446
+ const setting = getSetting('agent_messaging');
447
+ return setting?.mode || 'off';
448
+ }
449
+
450
+ export function setMessagingMode(mode) {
451
+ if (!['off', 'supervised', 'open'].includes(mode)) {
452
+ throw new Error('Invalid messaging mode');
453
+ }
454
+ setSetting('agent_messaging', { mode });
455
+ }
456
+
457
+ export function createAgentMessage(fromAgent, toAgent, message) {
458
+ const id = nanoid();
459
+ const mode = getMessagingMode();
460
+
461
+ if (mode === 'off') {
462
+ throw new Error('Agent messaging is disabled');
463
+ }
464
+
465
+ // In open mode, messages are delivered immediately
466
+ const status = mode === 'open' ? 'delivered' : 'pending';
467
+ const deliveredAt = mode === 'open' ? new Date().toISOString() : null;
468
+
469
+ db.prepare(`
470
+ INSERT INTO agent_messages (id, from_agent, to_agent, message, status, delivered_at)
471
+ VALUES (?, ?, ?, ?, ?, ?)
472
+ `).run(id, fromAgent, toAgent, message, status, deliveredAt);
473
+
474
+ return { id, status };
475
+ }
476
+
477
+ export function getAgentMessage(id) {
478
+ return db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
479
+ }
480
+
481
+ // Get messages for a specific agent (recipient)
482
+ export function getMessagesForAgent(agentName, unreadOnly = false) {
483
+ let sql = `
484
+ SELECT * FROM agent_messages
485
+ WHERE to_agent = ? AND status = 'delivered'
486
+ `;
487
+ if (unreadOnly) {
488
+ sql += ' AND read_at IS NULL';
489
+ }
490
+ sql += ' ORDER BY created_at DESC';
491
+ return db.prepare(sql).all(agentName);
492
+ }
493
+
494
+ // Mark message as read
495
+ export function markMessageRead(id, agentName) {
496
+ return db.prepare(`
497
+ UPDATE agent_messages
498
+ SET read_at = CURRENT_TIMESTAMP
499
+ WHERE id = ? AND to_agent = ? AND read_at IS NULL
500
+ `).run(id, agentName);
501
+ }
502
+
503
+ // Admin: list pending messages (for supervised mode)
504
+ export function listPendingMessages() {
505
+ return db.prepare(`
506
+ SELECT * FROM agent_messages
507
+ WHERE status = 'pending'
508
+ ORDER BY created_at DESC
509
+ `).all();
510
+ }
511
+
512
+ // Admin: approve message
513
+ export function approveAgentMessage(id) {
514
+ return db.prepare(`
515
+ UPDATE agent_messages
516
+ SET status = 'delivered', reviewed_at = CURRENT_TIMESTAMP, delivered_at = CURRENT_TIMESTAMP
517
+ WHERE id = ? AND status = 'pending'
518
+ `).run(id);
519
+ }
520
+
521
+ // Admin: reject message
522
+ export function rejectAgentMessage(id, reason) {
523
+ return db.prepare(`
524
+ UPDATE agent_messages
525
+ SET status = 'rejected', reviewed_at = CURRENT_TIMESTAMP, rejection_reason = ?
526
+ WHERE id = ? AND status = 'pending'
527
+ `).run(reason || 'No reason provided', id);
528
+ }
529
+
530
+ // Admin: list all messages (for UI)
531
+ export function listAgentMessages(status = null) {
532
+ if (status) {
533
+ return db.prepare('SELECT * FROM agent_messages WHERE status = ? ORDER BY created_at DESC').all(status);
534
+ }
535
+ return db.prepare('SELECT * FROM agent_messages ORDER BY created_at DESC').all();
536
+ }
537
+
538
+ // Admin: delete message
539
+ export function deleteAgentMessage(id) {
540
+ return db.prepare('DELETE FROM agent_messages WHERE id = ?').run(id);
541
+ }
542
+
543
+ // Admin: clear messages by status
544
+ export function clearAgentMessagesByStatus(status) {
545
+ if (status === 'all') {
546
+ return db.prepare("DELETE FROM agent_messages WHERE status IN ('delivered', 'rejected')").run();
547
+ }
548
+ return db.prepare('DELETE FROM agent_messages WHERE status = ?').run(status);
549
+ }
550
+
551
+ // Get counts for message queue
552
+ export function getMessageCounts() {
553
+ const rows = db.prepare('SELECT status, COUNT(*) as count FROM agent_messages GROUP BY status').all();
554
+ const counts = { all: 0, pending: 0, delivered: 0, rejected: 0 };
555
+ for (const row of rows) {
556
+ counts[row.status] = row.count;
557
+ counts.all += row.count;
558
+ }
559
+ return counts;
560
+ }
561
+
385
562
  export default db;
@@ -7,6 +7,30 @@ import { getSetting, updateQueueNotification } from './db.js';
7
7
  const DEFAULT_RETRY_ATTEMPTS = 3;
8
8
  const DEFAULT_RETRY_DELAY_MS = 5000;
9
9
 
10
+ /**
11
+ * Find the most relevant result to display (usually last one with a URL, or first failure)
12
+ */
13
+ function findRelevantResult(results, forError = false) {
14
+ if (!results?.length) return null;
15
+
16
+ if (forError) {
17
+ // For errors, find the first non-ok result
18
+ return results.find(r => !r.ok) || results[results.length - 1];
19
+ }
20
+
21
+ // For success, find the last result with a useful URL (PR, issue, etc.)
22
+ // Search backwards to get the most relevant one (e.g., PR URL, not branch ref)
23
+ for (let i = results.length - 1; i >= 0; i--) {
24
+ const r = results[i];
25
+ if (r.body?.html_url || r.body?.url) {
26
+ return r;
27
+ }
28
+ }
29
+
30
+ // Fallback to last result
31
+ return results[results.length - 1];
32
+ }
33
+
10
34
  /**
11
35
  * Format the notification text for a queue entry
12
36
  */
@@ -18,29 +42,29 @@ function formatNotification(entry) {
18
42
  text += `\n→ ${entry.service}/${entry.account_name}`;
19
43
 
20
44
  // Include key result info (e.g., PR URL, issue URL)
21
- if (entry.results?.length) {
22
- const firstResult = entry.results[0];
23
- if (firstResult.body) {
45
+ if (entry.status === 'completed' && entry.results?.length) {
46
+ const relevantResult = findRelevantResult(entry.results, false);
47
+ if (relevantResult?.body) {
24
48
  // GitHub PR/Issue
25
- if (firstResult.body.html_url) {
26
- text += `\n→ ${firstResult.body.html_url}`;
49
+ if (relevantResult.body.html_url) {
50
+ text += `\n→ ${relevantResult.body.html_url}`;
27
51
  }
28
52
  // Other useful fields
29
- else if (firstResult.body.url) {
30
- text += `\n→ ${firstResult.body.url}`;
53
+ else if (relevantResult.body.url) {
54
+ text += `\n→ ${relevantResult.body.url}`;
31
55
  }
32
56
  }
33
57
  }
34
58
 
35
59
  // Include error info for failures
36
60
  if (entry.status === 'failed' && entry.results?.length) {
37
- const firstResult = entry.results[0];
38
- if (firstResult.error) {
39
- text += `\n→ Error: ${firstResult.error}`;
40
- } else if (firstResult.body?.message) {
41
- text += `\n→ Error: ${firstResult.body.message}`;
42
- } else if (firstResult.status) {
43
- text += `\n→ Error: HTTP ${firstResult.status}`;
61
+ const failingResult = findRelevantResult(entry.results, true);
62
+ if (failingResult.error) {
63
+ text += `\n→ Error: ${failingResult.error}`;
64
+ } else if (failingResult.body?.message) {
65
+ text += `\n→ Error: ${failingResult.body.message}`;
66
+ } else if (!failingResult.ok && failingResult.status) {
67
+ text += `\n→ Error: HTTP ${failingResult.status}`;
44
68
  }
45
69
  }
46
70
 
@@ -166,10 +190,14 @@ function formatBatchLine(entry) {
166
190
  let line = `${emoji} #${entry.id.substring(0, 8)} - ${entry.service}/${entry.account_name}`;
167
191
 
168
192
  // Add brief result info
169
- if (entry.status === 'completed' && entry.results?.[0]?.body?.html_url) {
170
- line += ` - ${entry.results[0].body.html_url}`;
193
+ if (entry.status === 'completed') {
194
+ const relevantResult = findRelevantResult(entry.results, false);
195
+ if (relevantResult?.body?.html_url) {
196
+ line += ` - ${relevantResult.body.html_url}`;
197
+ }
171
198
  } else if (entry.status === 'failed') {
172
- const err = entry.results?.[0]?.error || entry.results?.[0]?.body?.message || `HTTP ${entry.results?.[0]?.status || '?'}`;
199
+ const failingResult = findRelevantResult(entry.results, true);
200
+ const err = failingResult?.error || failingResult?.body?.message || (failingResult && !failingResult.ok ? `HTTP ${failingResult.status || '?'}` : 'Unknown error');
173
201
  line += ` - ${err.substring(0, 50)}`;
174
202
  } else if (entry.status === 'rejected') {
175
203
  line += ` - ${(entry.rejection_reason || 'rejected').substring(0, 50)}`;