@perkos/perkos-a2a 0.8.3 → 0.8.5

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/dist/index.js CHANGED
@@ -1,403 +1,1381 @@
1
- /**
2
- * @perkos/perkos-a2a -- OpenClaw Plugin
3
- *
4
- * Agent-to-Agent (A2A) protocol communication plugin.
5
- * Adds tools for agents to discover peers, send tasks, and check task status.
6
- * Supports direct HTTP, relay-based NAT traversal, and session injection.
7
- *
8
- * v0.8.0: Uses enqueueSystemEvent + WebSocket wake for immediate task processing.
9
- */
10
- import { A2AServer, detectNetworking } from "./server.js";
11
- export { A2AServer, detectNetworking } from "./server.js";
12
- export { RelayHub } from "./relay.js";
13
- export { RelayClient } from "./relay-client.js";
14
- export * from "./types.js";
15
- function wakeGatewayAgent(requestHeartbeatNow, reason, logger, sessionKey = "agent:main") {
16
- if (typeof requestHeartbeatNow === "function") {
17
- try {
18
- requestHeartbeatNow({ reason, sessionKey });
19
- logger.info(`[perkos-a2a] Wake triggered via runtime.system.requestHeartbeatNow: ${reason} (session: ${sessionKey})`);
1
+ import { createRequire as __createRequire } from "module"; const require = __createRequire(import.meta.url);
2
+
3
+ // src/server.ts
4
+ import express from "express";
5
+ import { randomUUID as randomUUID3 } from "crypto";
6
+ import { homedir, networkInterfaces } from "os";
7
+
8
+ // src/relay-client.ts
9
+ import WebSocket from "ws";
10
+ import { randomUUID } from "crypto";
11
+ var MIN_RECONNECT_MS = 1e3;
12
+ var MAX_RECONNECT_MS = 6e4;
13
+ var HEARTBEAT_INTERVAL_MS = 25e3;
14
+ var RelayClient = class {
15
+ ws = null;
16
+ options;
17
+ logger;
18
+ reconnectMs = MIN_RECONNECT_MS;
19
+ reconnectTimer = null;
20
+ heartbeatTimer = null;
21
+ connected = false;
22
+ stopped = false;
23
+ pendingCallbacks = /* @__PURE__ */ new Map();
24
+ constructor(options) {
25
+ this.options = options;
26
+ this.logger = options.logger || { info: console.log, error: console.error };
27
+ }
28
+ isConnected() {
29
+ return this.connected;
30
+ }
31
+ start() {
32
+ this.stopped = false;
33
+ this.connect();
34
+ }
35
+ stop() {
36
+ this.stopped = true;
37
+ this.clearTimers();
38
+ if (this.ws) {
39
+ this.ws.close(1e3, "Client shutting down");
40
+ this.ws = null;
41
+ }
42
+ this.connected = false;
43
+ }
44
+ /** Send a task to another agent via the relay */
45
+ async sendTask(targetAgent, payload) {
46
+ return this.sendAndWait({
47
+ type: "task",
48
+ to: targetAgent,
49
+ id: randomUUID(),
50
+ from: this.options.agentName,
51
+ payload,
52
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
53
+ }, "task_response", 3e4);
54
+ }
55
+ /** Discover agents connected to the relay */
56
+ async discover() {
57
+ const response = await this.sendAndWait({
58
+ type: "discover",
59
+ id: randomUUID(),
60
+ from: this.options.agentName,
61
+ payload: {},
62
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
63
+ }, "discover_response", 1e4);
64
+ return response.payload.agents || [];
65
+ }
66
+ /** Send a task response back through the relay */
67
+ sendTaskResponse(originalMsg, result) {
68
+ const response = {
69
+ type: "task_response",
70
+ id: originalMsg.id,
71
+ from: this.options.agentName,
72
+ to: originalMsg.from,
73
+ payload: result,
74
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
75
+ };
76
+ this.send(response);
77
+ }
78
+ connect() {
79
+ if (this.stopped) return;
80
+ const url = this.options.relay.url;
81
+ this.logger.info(`[perkos-a2a] Connecting to relay: ${url}`);
82
+ try {
83
+ this.ws = new WebSocket(url);
84
+ } catch (err) {
85
+ const msg = err instanceof Error ? err.message : String(err);
86
+ this.logger.error(`[perkos-a2a] Failed to create WebSocket: ${msg}`);
87
+ this.scheduleReconnect();
88
+ return;
89
+ }
90
+ this.ws.on("open", () => {
91
+ this.connected = true;
92
+ this.reconnectMs = MIN_RECONNECT_MS;
93
+ this.logger.info("[perkos-a2a] Connected to relay hub");
94
+ this.register();
95
+ this.startHeartbeat();
96
+ });
97
+ this.ws.on("message", (data) => {
98
+ let msg;
99
+ try {
100
+ msg = JSON.parse(data.toString());
101
+ } catch {
102
+ this.logger.error("[perkos-a2a] Failed to parse relay message");
103
+ return;
104
+ }
105
+ this.handleMessage(msg);
106
+ });
107
+ this.ws.on("close", (code, reason) => {
108
+ this.connected = false;
109
+ this.clearTimers();
110
+ if (!this.stopped) {
111
+ this.logger.info(`[perkos-a2a] Relay connection closed (${code}: ${reason || "no reason"}). Reconnecting...`);
112
+ this.scheduleReconnect();
113
+ }
114
+ });
115
+ this.ws.on("error", (err) => {
116
+ this.logger.error(`[perkos-a2a] Relay WebSocket error: ${err.message}`);
117
+ });
118
+ }
119
+ register() {
120
+ const msg = {
121
+ type: "register",
122
+ id: randomUUID(),
123
+ from: this.options.agentName,
124
+ payload: {
125
+ agentName: this.options.agentName,
126
+ apiKey: this.options.relay.apiKey,
127
+ card: this.options.card
128
+ },
129
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
130
+ };
131
+ this.send(msg);
132
+ }
133
+ handleMessage(msg) {
134
+ const callback = this.pendingCallbacks.get(msg.id);
135
+ if (callback) {
136
+ this.pendingCallbacks.delete(msg.id);
137
+ callback(msg);
138
+ return;
139
+ }
140
+ switch (msg.type) {
141
+ case "register_ack":
142
+ this.logger.info("[perkos-a2a] Registered with relay hub");
143
+ break;
144
+ case "task":
145
+ this.options.onTask(msg);
146
+ break;
147
+ case "task_response":
148
+ this.logger.info(`[perkos-a2a] Received unmatched task response: ${msg.id}`);
149
+ break;
150
+ case "discover_response":
151
+ if (this.options.onDiscoverResponse) {
152
+ this.options.onDiscoverResponse(msg.payload.agents);
20
153
  }
21
- catch (err) {
22
- const msg = err instanceof Error ? err.message : String(err);
23
- logger.error(`[perkos-a2a] runtime.system.requestHeartbeatNow failed: ${msg}`);
154
+ break;
155
+ case "heartbeat_ack":
156
+ break;
157
+ case "error":
158
+ this.logger.error(`[perkos-a2a] Relay error: ${msg.payload.code} - ${msg.payload.message}`);
159
+ break;
160
+ default:
161
+ this.logger.info(`[perkos-a2a] Unknown relay message type: ${msg.type}`);
162
+ }
163
+ }
164
+ send(msg) {
165
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
166
+ this.ws.send(JSON.stringify(msg));
167
+ }
168
+ }
169
+ async sendAndWait(msg, expectedType, timeoutMs) {
170
+ return new Promise((resolve, reject) => {
171
+ const timer = setTimeout(() => {
172
+ this.pendingCallbacks.delete(msg.id);
173
+ reject(new Error(`Relay request timed out after ${timeoutMs}ms`));
174
+ }, timeoutMs);
175
+ this.pendingCallbacks.set(msg.id, (response) => {
176
+ clearTimeout(timer);
177
+ if (response.type === "error") {
178
+ reject(new Error(`Relay error: ${response.payload.message}`));
179
+ } else {
180
+ resolve(response);
24
181
  }
182
+ });
183
+ this.send(msg);
184
+ });
185
+ }
186
+ startHeartbeat() {
187
+ this.heartbeatTimer = setInterval(() => {
188
+ const msg = {
189
+ type: "heartbeat",
190
+ id: randomUUID(),
191
+ from: this.options.agentName,
192
+ payload: {},
193
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
194
+ };
195
+ this.send(msg);
196
+ }, HEARTBEAT_INTERVAL_MS);
197
+ }
198
+ scheduleReconnect() {
199
+ if (this.stopped) return;
200
+ this.reconnectTimer = setTimeout(() => {
201
+ this.connect();
202
+ }, this.reconnectMs);
203
+ this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
204
+ }
205
+ clearTimers() {
206
+ if (this.heartbeatTimer) {
207
+ clearInterval(this.heartbeatTimer);
208
+ this.heartbeatTimer = null;
25
209
  }
26
- else {
27
- logger.info("[perkos-a2a] runtime.system.requestHeartbeatNow unavailable — task will process on next agent turn");
210
+ if (this.reconnectTimer) {
211
+ clearTimeout(this.reconnectTimer);
212
+ this.reconnectTimer = null;
28
213
  }
29
- }
30
- export default function register(api) {
31
- const pluginConfig = api.config?.plugins?.entries?.["perkos-a2a"]?.config || {
32
- agentName: "agent",
33
- port: 5050,
34
- skills: [],
35
- peers: {},
214
+ }
215
+ };
216
+
217
+ // src/relay.ts
218
+ import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
219
+ import { randomUUID as randomUUID2 } from "crypto";
220
+ var DEFAULT_CONFIG = {
221
+ port: 6060,
222
+ apiKeys: [],
223
+ maxQueuePerAgent: 200,
224
+ rateLimitPerMinute: 60,
225
+ heartbeatIntervalMs: 3e4,
226
+ heartbeatTimeoutMs: 9e4
227
+ };
228
+ var RelayHub = class {
229
+ wss = null;
230
+ agents = /* @__PURE__ */ new Map();
231
+ offlineQueue = /* @__PURE__ */ new Map();
232
+ rateCounts = /* @__PURE__ */ new Map();
233
+ config;
234
+ heartbeatTimer = null;
235
+ logger;
236
+ constructor(config, logger) {
237
+ this.config = { ...DEFAULT_CONFIG, ...config };
238
+ this.logger = logger || { info: console.log, error: console.error };
239
+ }
240
+ start() {
241
+ this.wss = new WebSocketServer({ port: this.config.port });
242
+ this.logger.info(`[perkos-a2a] Relay hub listening on port ${this.config.port}`);
243
+ this.wss.on("connection", (ws, req) => {
244
+ const remoteAddr = req.socket.remoteAddress || "unknown";
245
+ this.logger.info(`[perkos-a2a] Relay connection from ${remoteAddr}`);
246
+ this.handleConnection(ws);
247
+ });
248
+ this.heartbeatTimer = setInterval(() => this.checkHeartbeats(), this.config.heartbeatIntervalMs);
249
+ }
250
+ stop() {
251
+ if (this.heartbeatTimer) {
252
+ clearInterval(this.heartbeatTimer);
253
+ this.heartbeatTimer = null;
254
+ }
255
+ if (this.wss) {
256
+ for (const agent of this.agents.values()) {
257
+ agent.ws.close(1001, "Relay shutting down");
258
+ }
259
+ this.wss.close();
260
+ this.wss = null;
261
+ this.logger.info("[perkos-a2a] Relay hub stopped");
262
+ }
263
+ }
264
+ getRegistry() {
265
+ const entries = [];
266
+ for (const agent of this.agents.values()) {
267
+ entries.push({
268
+ name: agent.name,
269
+ connectedAt: agent.connectedAt,
270
+ lastHeartbeat: agent.lastHeartbeat,
271
+ card: agent.card
272
+ });
273
+ }
274
+ return entries;
275
+ }
276
+ handleConnection(ws) {
277
+ let agentName = null;
278
+ ws.on("message", (data) => {
279
+ let msg;
280
+ try {
281
+ msg = JSON.parse(data.toString());
282
+ } catch {
283
+ this.sendError(ws, "invalid_json", "Failed to parse message");
284
+ return;
285
+ }
286
+ if (msg.type === "register") {
287
+ agentName = this.handleRegister(ws, msg);
288
+ return;
289
+ }
290
+ if (!agentName) {
291
+ this.sendError(ws, "not_registered", "Must register before sending messages");
292
+ return;
293
+ }
294
+ if (!this.checkRateLimit(agentName)) {
295
+ this.sendError(ws, "rate_limited", "Rate limit exceeded");
296
+ return;
297
+ }
298
+ switch (msg.type) {
299
+ case "task":
300
+ case "task_response":
301
+ this.routeMessage(agentName, msg);
302
+ break;
303
+ case "discover":
304
+ this.handleDiscover(ws, msg);
305
+ break;
306
+ case "heartbeat":
307
+ this.handleHeartbeat(agentName, msg);
308
+ break;
309
+ default:
310
+ this.sendError(ws, "unknown_type", `Unknown message type: ${msg.type}`);
311
+ }
312
+ });
313
+ ws.on("close", () => {
314
+ if (agentName) {
315
+ this.agents.delete(agentName);
316
+ this.logger.info(`[perkos-a2a] Agent disconnected: ${agentName}`);
317
+ }
318
+ });
319
+ ws.on("error", (err) => {
320
+ this.logger.error(`[perkos-a2a] WebSocket error for ${agentName || "unknown"}: ${err.message}`);
321
+ });
322
+ }
323
+ handleRegister(ws, msg) {
324
+ const name = msg.payload.agentName;
325
+ const apiKey = msg.payload.apiKey;
326
+ if (!name) {
327
+ this.sendError(ws, "missing_name", "agentName is required for registration");
328
+ return null;
329
+ }
330
+ if (this.config.apiKeys.length > 0 && !this.config.apiKeys.includes(apiKey)) {
331
+ this.sendError(ws, "auth_failed", "Invalid API key");
332
+ ws.close(4001, "Authentication failed");
333
+ return null;
334
+ }
335
+ const existing = this.agents.get(name);
336
+ if (existing) {
337
+ existing.ws.close(4002, "Replaced by new connection");
338
+ }
339
+ const now = (/* @__PURE__ */ new Date()).toISOString();
340
+ this.agents.set(name, {
341
+ name,
342
+ ws,
343
+ apiKey: apiKey || "",
344
+ card: msg.payload.card,
345
+ connectedAt: now,
346
+ lastHeartbeat: now
347
+ });
348
+ this.logger.info(`[perkos-a2a] Agent registered: ${name}`);
349
+ const ack = {
350
+ type: "register_ack",
351
+ id: msg.id,
352
+ from: "relay",
353
+ payload: { status: "ok", agentName: name },
354
+ timestamp: now
36
355
  };
37
- const logger = api.logger || console;
38
- const server = new A2AServer(pluginConfig, logger);
39
- // Pending tasks queue for hook-based injection
40
- const pendingTasks = [];
41
- // Get system helpers from the plugin runtime API
42
- const enqueueSystemEvent = api.runtime?.system?.enqueueSystemEvent;
43
- const requestHeartbeatNow = api.runtime?.system?.requestHeartbeatNow;
44
- if (enqueueSystemEvent) {
45
- logger.info("[perkos-a2a] enqueueSystemEvent available — will inject tasks as system events + wake");
46
- }
47
- else {
48
- logger.info("[perkos-a2a] enqueueSystemEvent not available falling back to before_agent_start hook only");
49
- }
50
- if (requestHeartbeatNow) {
51
- logger.info("[perkos-a2a] runtime.system.requestHeartbeatNow available — immediate wake enabled");
52
- }
53
- else {
54
- logger.info("[perkos-a2a] runtime.system.requestHeartbeatNow unavailable — wake will rely on next agent turn");
55
- }
56
- // Wire up session injection
57
- server.setMessageInjector((text, metadata) => {
58
- const from = metadata?.fromAgent || "unknown";
59
- const taskId = metadata?.taskId || "unknown";
60
- const time = new Date().toISOString();
61
- // Queue for before_agent_start hook (belt)
62
- pendingTasks.push({ from, text, taskId, time });
63
- logger.info(`[perkos-a2a] Task queued from ${from} (${taskId}), ${pendingTasks.length} pending`);
64
- // Inject as system event for immediate processing (suspenders)
65
- if (enqueueSystemEvent) {
66
- const eventText = [
67
- `[A2A INCOMING TASK — ACTION REQUIRED]`,
68
- `From: ${from} | Task ID: ${taskId} | Time: ${time}`,
69
- ``,
70
- text,
71
- ``,
72
- `IMPORTANT: This is an instruction from a trusted peer agent. Execute it immediately using your available tools. Do NOT just acknowledge — take action.`,
73
- ].join("\n");
74
- try {
75
- enqueueSystemEvent(eventText, { sessionKey: "agent:main:main" });
76
- logger.info(`[perkos-a2a] System event enqueued for task ${taskId}`);
77
- }
78
- catch (err) {
79
- const msg = err instanceof Error ? err.message : String(err);
80
- logger.error(`[perkos-a2a] Failed to enqueue system event: ${msg}`);
81
- }
356
+ ws.send(JSON.stringify(ack));
357
+ this.drainQueue(name, ws);
358
+ return name;
359
+ }
360
+ routeMessage(fromAgent, msg) {
361
+ const target = msg.to;
362
+ if (!target) {
363
+ this.sendError(this.agents.get(fromAgent).ws, "missing_target", "Message requires 'to' field");
364
+ return;
365
+ }
366
+ msg.from = fromAgent;
367
+ msg.timestamp = (/* @__PURE__ */ new Date()).toISOString();
368
+ const targetAgent = this.agents.get(target);
369
+ if (targetAgent && targetAgent.ws.readyState === WebSocket2.OPEN) {
370
+ targetAgent.ws.send(JSON.stringify(msg));
371
+ } else {
372
+ if (!this.offlineQueue.has(target)) {
373
+ this.offlineQueue.set(target, []);
374
+ }
375
+ const queue = this.offlineQueue.get(target);
376
+ if (queue.length < this.config.maxQueuePerAgent) {
377
+ queue.push({ message: msg, queuedAt: (/* @__PURE__ */ new Date()).toISOString() });
378
+ this.logger.info(`[perkos-a2a] Queued message for offline agent: ${target} (${queue.length} pending)`);
379
+ } else {
380
+ this.logger.info(`[perkos-a2a] Queue full for agent: ${target}, dropping message`);
381
+ const sender = this.agents.get(fromAgent);
382
+ if (sender) {
383
+ this.sendError(sender.ws, "queue_full", `Target agent ${target} queue is full`);
82
384
  }
83
- // Wake the agent immediately so it processes the task
84
- wakeGatewayAgent(requestHeartbeatNow, `[A2A] Incoming task from ${from} (${taskId}). Process it now.`, logger, "agent:main");
385
+ }
386
+ }
387
+ }
388
+ drainQueue(agentName, ws) {
389
+ const queue = this.offlineQueue.get(agentName);
390
+ if (!queue || queue.length === 0) return;
391
+ this.logger.info(`[perkos-a2a] Delivering ${queue.length} queued messages to ${agentName}`);
392
+ for (const item of queue) {
393
+ if (ws.readyState === WebSocket2.OPEN) {
394
+ ws.send(JSON.stringify(item.message));
395
+ }
396
+ }
397
+ this.offlineQueue.delete(agentName);
398
+ }
399
+ handleDiscover(ws, msg) {
400
+ const agents = this.getRegistry();
401
+ const response = {
402
+ type: "discover_response",
403
+ id: msg.id,
404
+ from: "relay",
405
+ payload: { agents },
406
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
407
+ };
408
+ ws.send(JSON.stringify(response));
409
+ }
410
+ handleHeartbeat(agentName, msg) {
411
+ const agent = this.agents.get(agentName);
412
+ if (agent) {
413
+ agent.lastHeartbeat = (/* @__PURE__ */ new Date()).toISOString();
414
+ }
415
+ const ack = {
416
+ type: "heartbeat_ack",
417
+ id: msg.id,
418
+ from: "relay",
419
+ payload: {},
420
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
421
+ };
422
+ const ws = agent?.ws;
423
+ if (ws && ws.readyState === WebSocket2.OPEN) {
424
+ ws.send(JSON.stringify(ack));
425
+ }
426
+ }
427
+ checkHeartbeats() {
428
+ const now = Date.now();
429
+ for (const [name, agent] of this.agents) {
430
+ const lastBeat = new Date(agent.lastHeartbeat).getTime();
431
+ if (now - lastBeat > this.config.heartbeatTimeoutMs) {
432
+ this.logger.info(`[perkos-a2a] Agent ${name} timed out (no heartbeat)`);
433
+ agent.ws.close(4003, "Heartbeat timeout");
434
+ this.agents.delete(name);
435
+ }
436
+ }
437
+ }
438
+ checkRateLimit(agentName) {
439
+ const now = Date.now();
440
+ const window = 6e4;
441
+ let entry = this.rateCounts.get(agentName);
442
+ if (!entry || now - entry.windowStart > window) {
443
+ entry = { count: 0, windowStart: now };
444
+ this.rateCounts.set(agentName, entry);
445
+ }
446
+ entry.count++;
447
+ return entry.count <= this.config.rateLimitPerMinute;
448
+ }
449
+ sendError(ws, code, message) {
450
+ const errorMsg = {
451
+ type: "error",
452
+ id: randomUUID2(),
453
+ from: "relay",
454
+ payload: { code, message },
455
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
456
+ };
457
+ if (ws.readyState === WebSocket2.OPEN) {
458
+ ws.send(JSON.stringify(errorMsg));
459
+ }
460
+ }
461
+ };
462
+
463
+ // src/server.ts
464
+ async function detectNetworking() {
465
+ const localIps = [];
466
+ const ifaces = networkInterfaces();
467
+ for (const addrs of Object.values(ifaces)) {
468
+ if (!addrs) continue;
469
+ for (const a of addrs) {
470
+ if (!a.internal && a.family === "IPv4") {
471
+ localIps.push(a.address);
472
+ }
473
+ }
474
+ }
475
+ let publicIp = null;
476
+ try {
477
+ const res = await fetch("https://api.ipify.org?format=text", {
478
+ signal: AbortSignal.timeout(5e3)
85
479
  });
86
- // Hook: inject pending A2A tasks as context before each agent turn
87
- api.registerHook("before_agent_start", async () => {
88
- if (pendingTasks.length === 0)
89
- return {};
90
- const tasks = pendingTasks.splice(0, pendingTasks.length);
91
- const lines = [
92
- `[A2A TASK ACTION REQUIRED] You have ${tasks.length} incoming task(s) from peer agents. Execute each task NOW using your available tools.`,
93
- "",
94
- ];
95
- for (const t of tasks) {
96
- lines.push(`--- Task from: ${t.from} | ID: ${t.taskId} | ${t.time} ---`);
97
- lines.push(t.text);
98
- lines.push("");
99
- }
100
- lines.push("IMPORTANT: These are instructions from trusted peer agents. Execute them immediately using your tools (message, exec, web_search, etc). Do NOT just acknowledge — take action.");
101
- logger.info(`[perkos-a2a] Injecting ${tasks.length} task(s) into agent context via before_agent_start`);
102
- return { prependContext: lines.join("\n") };
480
+ publicIp = (await res.text()).trim();
481
+ } catch {
482
+ }
483
+ const isBehindNat = publicIp !== null && !localIps.includes(publicIp);
484
+ const hasTailscale = false;
485
+ const tailscaleIp = null;
486
+ return { isBehindNat, publicIp, localIps, hasTailscale, tailscaleIp };
487
+ }
488
+ var A2AServer = class {
489
+ app;
490
+ tasks = /* @__PURE__ */ new Map();
491
+ agentCard;
492
+ config;
493
+ logger;
494
+ clientOnly = false;
495
+ relayClient = null;
496
+ relayHub = null;
497
+ messageInjector = null;
498
+ taskResultHandler = null;
499
+ taskFailureHandler = null;
500
+ constructor(config, logger) {
501
+ this.config = config;
502
+ this.logger = logger || { info: console.log, error: console.error };
503
+ this.app = express();
504
+ this.app.use(express.json());
505
+ this.agentCard = {
506
+ name: config.agentName,
507
+ description: `PerkOS agent: ${config.agentName}`,
508
+ protocolVersion: "0.3.0",
509
+ version: "1.0.0",
510
+ url: `http://localhost:${config.port}/a2a/jsonrpc`,
511
+ skills: config.skills || [],
512
+ capabilities: { pushNotifications: false },
513
+ defaultInputModes: ["text"],
514
+ defaultOutputModes: ["text"]
515
+ };
516
+ this.setupRoutes();
517
+ }
518
+ /** Set the message injector for delivering tasks into the agent session */
519
+ setMessageInjector(injector) {
520
+ this.messageInjector = injector;
521
+ }
522
+ setTaskResultHandler(handler) {
523
+ this.taskResultHandler = handler;
524
+ }
525
+ setTaskFailureHandler(handler) {
526
+ this.taskFailureHandler = handler;
527
+ }
528
+ isClientOnly() {
529
+ return this.clientOnly;
530
+ }
531
+ isRelayConnected() {
532
+ return this.relayClient?.isConnected() || false;
533
+ }
534
+ authMiddleware() {
535
+ return (req, res, next) => {
536
+ if (!this.config.auth?.requireApiKey) {
537
+ next();
538
+ return;
539
+ }
540
+ const apiKey = req.headers["x-api-key"] || req.headers["authorization"]?.replace(/^Bearer\s+/i, "") || req.query["apiKey"];
541
+ if (!apiKey || !this.config.auth.apiKeys.includes(apiKey)) {
542
+ res.status(401).json({ error: "Unauthorized: invalid or missing API key" });
543
+ return;
544
+ }
545
+ next();
546
+ };
547
+ }
548
+ setupRoutes() {
549
+ this.app.get("/.well-known/agent-card.json", (_req, res) => {
550
+ res.json(this.agentCard);
103
551
  });
104
- // Start A2A server as background service
105
- api.registerService({
106
- id: "perkos-a2a",
107
- start: () => {
108
- server.start();
109
- logger.info(`[perkos-a2a] A2A server started for ${pluginConfig.agentName}`);
110
- },
111
- stop: () => {
112
- logger.info("[perkos-a2a] A2A server stopping");
113
- },
552
+ this.app.get("/health", (_req, res) => {
553
+ res.json({
554
+ ok: true,
555
+ agent: this.config.agentName,
556
+ protocol: "a2a",
557
+ version: "0.8.1",
558
+ peers: Object.keys(this.config.peers),
559
+ taskCount: this.tasks.size,
560
+ relayConnected: this.isRelayConnected()
561
+ });
114
562
  });
115
- // Tool: Send a task to a peer agent
116
- api.registerTool({
117
- name: "perkos_a2a_send",
118
- description: "Send a task to another agent in the council via A2A protocol. " +
119
- "Use this to delegate work to a peer agent.",
120
- parameters: {
121
- type: "object",
122
- properties: {
123
- target: {
124
- type: "string",
125
- description: "Name of the target agent (e.g. 'mimir', 'tyr', 'bragi', 'idunn')",
126
- },
127
- message: {
128
- type: "string",
129
- description: "The task message to send to the agent",
130
- },
131
- },
132
- required: ["target", "message"],
133
- },
134
- async execute(_id, params) {
135
- try {
136
- const result = await server.sendTask(params.target, params.message);
137
- if (result.error) {
138
- return {
139
- content: [
140
- {
141
- type: "text",
142
- text: `Failed to send task to ${params.target}: ${result.error.message}`,
143
- },
144
- ],
145
- };
146
- }
147
- const task = result.result;
148
- return {
149
- content: [
150
- {
151
- type: "text",
152
- text: [
153
- `Task sent to ${params.target} successfully.`,
154
- `Task ID: ${task?.id}`,
155
- `Status: ${task?.status?.state}`,
156
- ].join("\n"),
157
- },
158
- ],
159
- };
160
- }
161
- catch (err) {
162
- const msg = err instanceof Error ? err.message : String(err);
163
- return {
164
- content: [{ type: "text", text: `Error sending task: ${msg}` }],
165
- };
166
- }
167
- },
563
+ const auth = this.authMiddleware();
564
+ this.app.post("/a2a/jsonrpc", auth, async (req, res) => {
565
+ const { method, params, id } = req.body;
566
+ try {
567
+ let result;
568
+ switch (method) {
569
+ case "message/send":
570
+ result = await this.handleSendMessage(params, id);
571
+ break;
572
+ case "tasks/get":
573
+ result = this.handleGetTask(params, id);
574
+ break;
575
+ case "tasks/list":
576
+ result = this.handleListTasks(id);
577
+ break;
578
+ case "tasks/cancel":
579
+ result = this.handleCancelTask(params, id);
580
+ break;
581
+ case "agent/card":
582
+ result = this.success(id, this.agentCard);
583
+ break;
584
+ default:
585
+ result = this.error(id, -32601, `Method not found: ${method}`);
586
+ }
587
+ res.json(result);
588
+ } catch (err) {
589
+ const msg = err instanceof Error ? err.message : String(err);
590
+ this.logger.error(`[perkos-a2a] RPC error: ${msg}`);
591
+ res.json(this.error(id, -32603, msg));
592
+ }
168
593
  });
169
- // Tool: Discover peer agents
170
- api.registerTool({
171
- name: "perkos_a2a_discover",
172
- description: "Discover all peer agents in the council and their capabilities. " +
173
- "Returns each agent's status, name, description, and skills.",
174
- parameters: {
175
- type: "object",
176
- properties: {},
177
- },
178
- async execute() {
179
- try {
180
- const peers = await server.discoverPeers();
181
- const lines = ["## Council Agents\n"];
182
- for (const [name, info] of Object.entries(peers)) {
183
- if (info.status === "online" && info.card) {
184
- lines.push(`### ${info.card.name} (${name})`);
185
- lines.push(`- **Status:** online`);
186
- lines.push(`- **Description:** ${info.card.description}`);
187
- lines.push(`- **Skills:** ${info.card.skills.map((s) => s.name).join(", ")}`);
188
- lines.push("");
189
- }
190
- else {
191
- lines.push(`### ${name}`);
192
- lines.push(`- **Status:** offline`);
193
- lines.push("");
194
- }
195
- }
196
- return {
197
- content: [{ type: "text", text: lines.join("\n") }],
198
- };
199
- }
200
- catch (err) {
201
- const msg = err instanceof Error ? err.message : String(err);
202
- return {
203
- content: [{ type: "text", text: `Error discovering peers: ${msg}` }],
204
- };
205
- }
206
- },
594
+ this.app.get("/a2a/peers", auth, async (_req, res) => {
595
+ const results = await this.discoverPeers();
596
+ res.json(results);
207
597
  });
208
- // Tool: Get task status
209
- api.registerTool({
210
- name: "perkos_a2a_status",
211
- description: "Get the status of a previously sent A2A task by its ID.",
212
- parameters: {
213
- type: "object",
214
- properties: {
215
- target: {
216
- type: "string",
217
- description: "Name of the agent that received the task",
218
- },
219
- taskId: {
220
- type: "string",
221
- description: "The task ID returned from perkos_a2a_send",
222
- },
223
- },
224
- required: ["target", "taskId"],
225
- },
226
- async execute(_id, params) {
227
- const targetUrl = pluginConfig.peers[params.target];
228
- if (!targetUrl) {
229
- return {
230
- content: [
231
- { type: "text", text: `Unknown agent: ${params.target}` },
232
- ],
233
- };
234
- }
235
- try {
236
- const headers = { "Content-Type": "application/json" };
237
- const peerAuth = pluginConfig.peerAuth?.[params.target];
238
- if (peerAuth) {
239
- headers["x-api-key"] = peerAuth;
240
- }
241
- const response = await fetch(`${targetUrl}/a2a/jsonrpc`, {
242
- method: "POST",
243
- headers,
244
- body: JSON.stringify({
245
- jsonrpc: "2.0",
246
- method: "tasks/get",
247
- id: crypto.randomUUID(),
248
- params: { id: params.taskId },
249
- }),
250
- });
251
- const data = (await response.json());
252
- if (data.error) {
253
- return {
254
- content: [
255
- { type: "text", text: `Error: ${data.error.message}` },
256
- ],
257
- };
258
- }
259
- const task = data.result;
260
- return {
261
- content: [
262
- {
263
- type: "text",
264
- text: [
265
- `**Task:** ${task.id}`,
266
- `**Status:** ${task.status.state}`,
267
- `**Updated:** ${task.status.timestamp}`,
268
- task.artifacts?.length
269
- ? `**Artifacts:** ${task.artifacts.length}`
270
- : "",
271
- ]
272
- .filter(Boolean)
273
- .join("\n"),
274
- },
275
- ],
276
- };
277
- }
278
- catch (err) {
279
- const msg = err instanceof Error ? err.message : String(err);
280
- return {
281
- content: [{ type: "text", text: `Error: ${msg}` }],
282
- };
283
- }
284
- },
598
+ this.app.post("/a2a/send", auth, async (req, res) => {
599
+ const { target, message } = req.body;
600
+ try {
601
+ const result = await this.sendTask(target, message);
602
+ res.json({ ok: true, result });
603
+ } catch (err) {
604
+ const msg = err instanceof Error ? err.message : String(err);
605
+ res.status(500).json({ ok: false, error: msg });
606
+ }
285
607
  });
286
- // Gateway RPC method
287
- api.registerGatewayMethod("perkos-a2a.status", ({ respond }) => {
288
- respond(true, {
289
- agent: pluginConfig.agentName,
290
- port: pluginConfig.port,
291
- mode: pluginConfig.mode || "auto",
292
- clientOnly: server.isClientOnly(),
293
- relayConnected: server.isRelayConnected(),
294
- relayUrl: pluginConfig.relay?.url || null,
295
- peers: Object.keys(pluginConfig.peers),
296
- pendingTasks: pendingTasks.length,
297
- hasSystemEventInjection: !!enqueueSystemEvent,
298
- protocol: "a2a",
299
- version: "0.8.1",
608
+ }
609
+ async handleSendMessage(params, rpcId) {
610
+ const message = params.message;
611
+ const taskId = randomUUID3();
612
+ const contextId = message?.contextId || randomUUID3();
613
+ const task = {
614
+ kind: "task",
615
+ id: taskId,
616
+ contextId,
617
+ status: { state: "submitted", timestamp: (/* @__PURE__ */ new Date()).toISOString() },
618
+ messages: [message],
619
+ artifacts: [],
620
+ metadata: {
621
+ fromAgent: message?.metadata?.fromAgent || "unknown"
622
+ }
623
+ };
624
+ this.tasks.set(taskId, task);
625
+ this.logger.info(
626
+ `[perkos-a2a] Task ${taskId} received from ${task.metadata?.fromAgent}`
627
+ );
628
+ this.processTask(task);
629
+ return this.success(rpcId, task);
630
+ }
631
+ async processTask(task) {
632
+ task.status = { state: "working", timestamp: (/* @__PURE__ */ new Date()).toISOString() };
633
+ const textParts = task.messages.flatMap((m) => m.parts || []).filter((p) => p.kind === "text").map((p) => p.text).join("\n");
634
+ try {
635
+ if (this.messageInjector) {
636
+ this.messageInjector(textParts, {
637
+ source: "a2a",
638
+ fromAgent: task.metadata?.fromAgent,
639
+ taskId: task.id
300
640
  });
301
- });
302
- // CLI command
303
- api.registerCli(({ program }) => {
304
- const cmd = program.command("perkos-a2a").description("PerkOS A2A protocol tools");
305
- cmd
306
- .command("status")
307
- .description("Show A2A agent status")
308
- .action(async () => {
309
- console.log(`Agent: ${pluginConfig.agentName}`);
310
- console.log(`Port: ${pluginConfig.port}`);
311
- console.log(`Mode: ${pluginConfig.mode || "auto"}`);
312
- console.log(`Client-only: ${server.isClientOnly()}`);
313
- console.log(`Relay connected: ${server.isRelayConnected()}`);
314
- console.log(`Relay URL: ${pluginConfig.relay?.url || "(not configured)"}`);
315
- console.log(`Auth required: ${pluginConfig.auth?.requireApiKey || false}`);
316
- console.log(`Peers: ${Object.keys(pluginConfig.peers).join(", ") || "(none)"}`);
317
- console.log(`System event injection: ${!!enqueueSystemEvent}`);
641
+ task.artifacts.push({
642
+ kind: "artifact",
643
+ artifactId: randomUUID3(),
644
+ parts: [{ kind: "text", text: "Task injected into agent session" }]
318
645
  });
319
- cmd
320
- .command("discover")
321
- .description("Discover peer agents")
322
- .action(async () => {
323
- const peers = await server.discoverPeers();
324
- for (const [name, info] of Object.entries(peers)) {
325
- console.log(`${name}: ${info.status}${info.card ? ` -- ${info.card.description}` : ""}`);
326
- }
646
+ } else {
647
+ const fs = await import("fs");
648
+ const taskDir = this.config.workspacePath || `${homedir()}/.openclaw/workspace/memory`;
649
+ if (!fs.existsSync(taskDir)) {
650
+ fs.mkdirSync(taskDir, { recursive: true });
651
+ }
652
+ const taskFile = `${taskDir}/a2a-task-${task.id}.md`;
653
+ const content = [
654
+ "# A2A Task",
655
+ "",
656
+ `**From:** ${task.metadata?.fromAgent}`,
657
+ `**Task ID:** ${task.id}`,
658
+ `**Time:** ${(/* @__PURE__ */ new Date()).toISOString()}`,
659
+ "",
660
+ "## Message",
661
+ "",
662
+ textParts,
663
+ ""
664
+ ].join("\n");
665
+ fs.writeFileSync(taskFile, content);
666
+ task.artifacts.push({
667
+ kind: "artifact",
668
+ artifactId: randomUUID3(),
669
+ parts: [{ kind: "text", text: `Task queued: ${taskFile}` }]
327
670
  });
328
- cmd
329
- .command("send <target> <message>")
330
- .description("Send a task to a peer agent")
331
- .action(async (target, message) => {
332
- const result = await server.sendTask(target, message);
333
- console.log(JSON.stringify(result, null, 2));
671
+ }
672
+ if (this.taskResultHandler) {
673
+ await this.taskResultHandler(task, textParts);
674
+ } else {
675
+ task.status = {
676
+ state: "completed",
677
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
678
+ };
679
+ }
680
+ this.logger.info(`[perkos-a2a] Task ${task.id} completed`);
681
+ } catch (err) {
682
+ const msg = err instanceof Error ? err.message : String(err);
683
+ if (this.taskFailureHandler) {
684
+ await this.taskFailureHandler(task, msg);
685
+ } else {
686
+ task.status = {
687
+ state: "failed",
688
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
689
+ message: {
690
+ role: "agent",
691
+ parts: [{ kind: "text", text: msg }]
692
+ }
693
+ };
694
+ }
695
+ this.logger.error(`[perkos-a2a] Task ${task.id} failed: ${msg}`);
696
+ }
697
+ }
698
+ handleGetTask(params, rpcId) {
699
+ const task = this.tasks.get(params?.id);
700
+ if (!task) return this.error(rpcId, 404, "Task not found");
701
+ return this.success(rpcId, task);
702
+ }
703
+ handleListTasks(rpcId) {
704
+ const allTasks = Array.from(this.tasks.values()).sort(
705
+ (a, b) => new Date(b.status.timestamp).getTime() - new Date(a.status.timestamp).getTime()
706
+ );
707
+ return this.success(rpcId, { tasks: allTasks, nextPageToken: "" });
708
+ }
709
+ handleCancelTask(params, rpcId) {
710
+ const task = this.tasks.get(params?.id);
711
+ if (!task) return this.error(rpcId, 404, "Task not found");
712
+ if (["completed", "failed", "canceled"].includes(task.status.state)) {
713
+ return this.error(rpcId, 409, "Task not cancelable");
714
+ }
715
+ task.status = { state: "canceled", timestamp: (/* @__PURE__ */ new Date()).toISOString() };
716
+ return this.success(rpcId, task);
717
+ }
718
+ /** Send a task to a peer agent via A2A protocol (direct HTTP or relay) */
719
+ async sendTask(targetAgent, messageText) {
720
+ const targetUrl = this.config.peers[targetAgent];
721
+ if (targetUrl) {
722
+ try {
723
+ return await this.sendTaskDirect(targetAgent, targetUrl, messageText);
724
+ } catch (err) {
725
+ if (!this.relayClient?.isConnected()) {
726
+ throw err;
727
+ }
728
+ this.logger.info(
729
+ `[perkos-a2a] Direct send to ${targetAgent} failed, falling back to relay`
730
+ );
731
+ }
732
+ }
733
+ if (this.relayClient?.isConnected()) {
734
+ return this.sendTaskViaRelay(targetAgent, messageText);
735
+ }
736
+ throw new Error(
737
+ `Cannot reach ${targetAgent}: no direct URL configured and relay not connected. Known peers: ${Object.keys(this.config.peers).join(", ")}`
738
+ );
739
+ }
740
+ async sendTaskDirect(targetAgent, targetUrl, messageText) {
741
+ const headers = { "Content-Type": "application/json" };
742
+ const peerAuth = this.config.peerAuth?.[targetAgent];
743
+ if (peerAuth) {
744
+ headers["x-api-key"] = peerAuth;
745
+ }
746
+ const payload = {
747
+ jsonrpc: "2.0",
748
+ method: "message/send",
749
+ id: randomUUID3(),
750
+ params: {
751
+ message: {
752
+ kind: "message",
753
+ messageId: randomUUID3(),
754
+ role: "user",
755
+ parts: [{ kind: "text", text: messageText }],
756
+ metadata: { fromAgent: this.config.agentName }
757
+ }
758
+ }
759
+ };
760
+ const response = await fetch(`${targetUrl}/a2a/jsonrpc`, {
761
+ method: "POST",
762
+ headers,
763
+ body: JSON.stringify(payload)
764
+ });
765
+ return await response.json();
766
+ }
767
+ async sendTaskViaRelay(targetAgent, messageText) {
768
+ const rpcId = randomUUID3();
769
+ const result = await this.relayClient.sendTask(targetAgent, {
770
+ jsonrpc: "2.0",
771
+ method: "message/send",
772
+ id: rpcId,
773
+ params: {
774
+ message: {
775
+ kind: "message",
776
+ messageId: randomUUID3(),
777
+ role: "user",
778
+ parts: [{ kind: "text", text: messageText }],
779
+ metadata: { fromAgent: this.config.agentName }
780
+ }
781
+ }
782
+ });
783
+ return result.payload || this.success(rpcId, result.payload);
784
+ }
785
+ /** Discover all peer agents (direct + relay) */
786
+ async discoverPeers() {
787
+ const results = {};
788
+ for (const [name, url] of Object.entries(this.config.peers)) {
789
+ try {
790
+ const r = await fetch(`${url}/.well-known/agent-card.json`, {
791
+ signal: AbortSignal.timeout(3e3)
334
792
  });
335
- cmd
336
- .command("setup")
337
- .description("Detect networking environment and show recommendations")
338
- .action(async () => {
339
- console.log("[perkos-a2a] Detecting environment...\n");
340
- const isMac = process.platform === "darwin";
341
- console.log(`Platform: ${process.platform}${isMac ? " (macOS)" : ""}`);
342
- const net = await detectNetworking();
343
- console.log(`Public IP: ${net.publicIp || "unknown"}`);
344
- console.log(`Local IPs: ${net.localIps.join(", ") || "none"}`);
345
- console.log(`Behind NAT: ${net.isBehindNat ? "yes" : "no"}`);
346
- console.log(`Tailscale: ${net.hasTailscale ? "installed" : "not found"}${net.tailscaleIp ? ` (${net.tailscaleIp})` : ""}`);
347
- let portAvailable = true;
348
- try {
349
- const netMod = await import("net");
350
- await new Promise((resolve, reject) => {
351
- const srv = netMod.createServer();
352
- srv.once("error", (err) => {
353
- if (err.code === "EADDRINUSE") {
354
- portAvailable = false;
355
- resolve();
356
- }
357
- else {
358
- reject(err);
359
- }
360
- });
361
- srv.listen(pluginConfig.port, () => {
362
- srv.close(() => resolve());
363
- });
364
- });
365
- }
366
- catch {
367
- // ignore
368
- }
369
- console.log(`Port ${pluginConfig.port}: ${portAvailable ? "available" : "IN USE"}`);
370
- console.log(`\nRelay: ${pluginConfig.relay?.enabled ? "enabled" : "disabled"}`);
371
- if (pluginConfig.relay?.url) {
372
- console.log(`Relay URL: ${pluginConfig.relay.url}`);
373
- }
374
- console.log(`Auth: ${pluginConfig.auth?.requireApiKey ? "enabled" : "disabled"}`);
375
- console.log(`System event injection: ${!!enqueueSystemEvent}`);
376
- console.log("\n--- Recommendations ---\n");
377
- if (!net.isBehindNat) {
378
- console.log("You have a public IP. Configure peers with your public IP.");
379
- console.log(` Your A2A URL: http://${net.publicIp}:${pluginConfig.port}/a2a/jsonrpc`);
380
- }
381
- else if (pluginConfig.relay?.enabled) {
382
- console.log("You are behind NAT but relay is configured. Bidirectional communication via relay.");
383
- }
384
- else if (net.hasTailscale && net.tailscaleIp) {
385
- console.log("Use your Tailscale IP for peers.");
386
- console.log(` Your A2A URL: http://${net.tailscaleIp}:${pluginConfig.port}/a2a/jsonrpc`);
387
- }
388
- else {
389
- console.log("You are behind NAT. Options:");
390
- console.log(" 1) Configure a relay hub (recommended) - set relay.url and relay.enabled in config");
391
- console.log(" 2) Install Tailscale - https://tailscale.com");
392
- console.log(" 3) Set up a Cloudflare Tunnel");
393
- console.log(' 4) Client-only mode (can send but not receive) - set mode: "client-only"');
793
+ results[name] = { status: "online", card: await r.json() };
794
+ } catch {
795
+ results[name] = { status: "offline" };
796
+ }
797
+ }
798
+ if (this.relayClient?.isConnected()) {
799
+ try {
800
+ const relayAgents = await this.relayClient.discover();
801
+ for (const agent of relayAgents) {
802
+ if (agent.name === this.config.agentName) continue;
803
+ const existing = results[agent.name];
804
+ if (!existing || existing.status === "offline") {
805
+ results[agent.name] = {
806
+ status: "online",
807
+ card: agent.card
808
+ };
809
+ }
810
+ }
811
+ } catch (err) {
812
+ const msg = err instanceof Error ? err.message : String(err);
813
+ this.logger.error(`[perkos-a2a] Relay discovery failed: ${msg}`);
814
+ }
815
+ }
816
+ return results;
817
+ }
818
+ success(id, result) {
819
+ return { jsonrpc: "2.0", id, result };
820
+ }
821
+ error(id, code, message) {
822
+ return { jsonrpc: "2.0", id, error: { code, message } };
823
+ }
824
+ /** Handle an inbound task received via the relay */
825
+ handleRelayTask(msg) {
826
+ const payload = msg.payload;
827
+ const params = payload.params || payload;
828
+ const rpcId = payload.id || msg.id;
829
+ this.handleSendMessage(params, rpcId).then((response) => {
830
+ this.relayClient?.sendTaskResponse(msg, response);
831
+ }).catch((err) => {
832
+ const errMsg = err instanceof Error ? err.message : String(err);
833
+ this.logger.error(`[perkos-a2a] Failed to process relay task: ${errMsg}`);
834
+ this.relayClient?.sendTaskResponse(msg, this.error(rpcId, -32603, errMsg));
835
+ });
836
+ }
837
+ startRelayClient() {
838
+ const relay = this.config.relay;
839
+ if (!relay?.enabled || !relay?.url) return;
840
+ this.relayClient = new RelayClient({
841
+ agentName: this.config.agentName,
842
+ relay,
843
+ card: this.agentCard,
844
+ onTask: (msg) => this.handleRelayTask(msg),
845
+ logger: this.logger
846
+ });
847
+ this.relayClient.start();
848
+ this.logger.info(`[perkos-a2a] Relay client started, connecting to ${relay.url}`);
849
+ }
850
+ startRelayHub() {
851
+ const relayConfig = this.config.relay;
852
+ this.relayHub = new RelayHub(
853
+ {
854
+ port: this.config.port,
855
+ apiKeys: this.config.auth?.apiKeys || [],
856
+ maxQueuePerAgent: 200,
857
+ rateLimitPerMinute: 60,
858
+ heartbeatIntervalMs: 3e4,
859
+ heartbeatTimeoutMs: 9e4
860
+ },
861
+ this.logger
862
+ );
863
+ this.relayHub.start();
864
+ }
865
+ async start() {
866
+ const mode = this.config.mode || "auto";
867
+ if (mode === "relay") {
868
+ this.clientOnly = true;
869
+ this.startRelayHub();
870
+ return;
871
+ }
872
+ if (mode === "client-only") {
873
+ this.clientOnly = true;
874
+ this.startRelayClient();
875
+ if (!this.config.relay?.enabled) {
876
+ this.logger.info(
877
+ "[perkos-a2a] Running in client-only mode. Configure relay for NAT traversal or set up Tailscale/tunnel."
878
+ );
879
+ }
880
+ return;
881
+ }
882
+ if (mode === "full") {
883
+ this.tryListen(this.config.port);
884
+ this.startRelayClient();
885
+ return;
886
+ }
887
+ try {
888
+ const net = await detectNetworking();
889
+ if (net.isBehindNat && !net.hasTailscale) {
890
+ if (this.config.relay?.enabled) {
891
+ this.logger.info(
892
+ "[perkos-a2a] Behind NAT, using relay for bidirectional communication"
893
+ );
894
+ this.clientOnly = true;
895
+ this.startRelayClient();
896
+ } else {
897
+ this.logger.info(
898
+ "[perkos-a2a] Running in client-only mode (behind NAT). Configure relay or Tailscale for bidirectional A2A."
899
+ );
900
+ this.clientOnly = true;
901
+ }
902
+ return;
903
+ }
904
+ if (net.isBehindNat && net.hasTailscale && net.tailscaleIp) {
905
+ this.logger.info(
906
+ `[perkos-a2a] Behind NAT but Tailscale detected (${net.tailscaleIp}). Starting server.`
907
+ );
908
+ }
909
+ } catch {
910
+ }
911
+ this.tryListen(this.config.port);
912
+ this.startRelayClient();
913
+ }
914
+ tryListen(port, attempt = 1) {
915
+ const maxAttempts = 3;
916
+ try {
917
+ const srv = this.app.listen(port, "0.0.0.0", () => {
918
+ this.logger.info(
919
+ `[perkos-a2a] ${this.config.agentName} server on port ${port}`
920
+ );
921
+ this.agentCard.url = `http://localhost:${port}/a2a/jsonrpc`;
922
+ });
923
+ srv.on("error", (err) => {
924
+ if (err.code === "EADDRINUSE" && attempt < maxAttempts) {
925
+ const nextPort = port + 1;
926
+ this.logger.info(`[perkos-a2a] Port ${port} in use, trying ${nextPort}`);
927
+ this.tryListen(nextPort, attempt + 1);
928
+ } else if (err.code === "EADDRINUSE") {
929
+ this.logger.info(
930
+ `[perkos-a2a] Ports ${this.config.port}-${port} all in use. Falling back to client-only mode.`
931
+ );
932
+ this.clientOnly = true;
933
+ } else {
934
+ this.logger.error(`[perkos-a2a] Server error: ${err.message}`);
935
+ }
936
+ });
937
+ } catch (err) {
938
+ const msg = err instanceof Error ? err.message : String(err);
939
+ this.logger.error(`[perkos-a2a] Failed to start server: ${msg}`);
940
+ }
941
+ }
942
+ getExpressApp() {
943
+ return this.app;
944
+ }
945
+ };
946
+
947
+ // src/index.ts
948
+ import { randomUUID as randomUUID4 } from "crypto";
949
+ import path from "path";
950
+ function wakeGatewayAgent(requestHeartbeatNow, reason, logger, sessionKey = "agent:main") {
951
+ if (typeof requestHeartbeatNow === "function") {
952
+ try {
953
+ requestHeartbeatNow({ reason, sessionKey });
954
+ logger.info(`[perkos-a2a] Wake triggered via runtime.system.requestHeartbeatNow: ${reason} (session: ${sessionKey})`);
955
+ } catch (err) {
956
+ const msg = err instanceof Error ? err.message : String(err);
957
+ logger.error(`[perkos-a2a] runtime.system.requestHeartbeatNow failed: ${msg}`);
958
+ }
959
+ } else {
960
+ logger.info("[perkos-a2a] runtime.system.requestHeartbeatNow unavailable \u2014 task will process on next agent turn");
961
+ }
962
+ }
963
+ function register(api) {
964
+ const pluginConfig = api.config?.plugins?.entries?.["perkos-a2a"]?.config || {
965
+ agentName: "agent",
966
+ port: 5050,
967
+ skills: [],
968
+ peers: {}
969
+ };
970
+ const logger = api.logger || console;
971
+ const server = new A2AServer(pluginConfig, logger);
972
+ const pendingTasks = [];
973
+ const enqueueSystemEvent = api.runtime?.system?.enqueueSystemEvent;
974
+ const requestHeartbeatNow = api.runtime?.system?.requestHeartbeatNow;
975
+ if (enqueueSystemEvent) {
976
+ logger.info("[perkos-a2a] enqueueSystemEvent available \u2014 will inject tasks as system events + wake");
977
+ } else {
978
+ logger.info("[perkos-a2a] enqueueSystemEvent not available \u2014 falling back to before_agent_start hook only");
979
+ }
980
+ if (requestHeartbeatNow) {
981
+ logger.info("[perkos-a2a] runtime.system.requestHeartbeatNow available \u2014 immediate wake enabled");
982
+ } else {
983
+ logger.info("[perkos-a2a] runtime.system.requestHeartbeatNow unavailable \u2014 wake will rely on next agent turn");
984
+ }
985
+ server.setTaskResultHandler(async (task, text) => {
986
+ const cfg = await api.runtime?.config?.loadConfig?.();
987
+ if (!api.runtime?.agent?.runEmbeddedAgent || !cfg) {
988
+ task.status = {
989
+ state: "completed",
990
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
991
+ };
992
+ return;
993
+ }
994
+ const agentDir = api.runtime.agent.resolveAgentDir(cfg);
995
+ const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(cfg);
996
+ await api.runtime.agent.ensureAgentWorkspace(cfg);
997
+ const sessionId = `perkos-a2a:task:${task.id}`;
998
+ const prompt = [
999
+ `You are handling an incoming A2A task from agent ${task.metadata?.fromAgent || "unknown"}.`,
1000
+ `Task ID: ${task.id}`,
1001
+ `Context ID: ${task.contextId}`,
1002
+ "",
1003
+ "Execute the request below and return the actual final answer for the peer agent.",
1004
+ "Do not describe internal steps unless the task explicitly asks for them.",
1005
+ "Return only the useful final response.",
1006
+ "",
1007
+ text
1008
+ ].join("\n");
1009
+ const result = await api.runtime.agent.runEmbeddedAgent({
1010
+ sessionId,
1011
+ runId: randomUUID4(),
1012
+ sessionFile: path.join(agentDir, "sessions", `perkos-a2a-task-${task.id}.jsonl`),
1013
+ workspaceDir,
1014
+ prompt,
1015
+ timeoutMs: api.runtime.agent.resolveAgentTimeoutMs(cfg)
1016
+ });
1017
+ const finalText = (result?.meta?.finalAssistantVisibleText || result?.payloads?.map((p) => p?.text).filter(Boolean).join("\n\n") || "").trim();
1018
+ if (finalText) {
1019
+ task.artifacts.push({
1020
+ kind: "artifact",
1021
+ artifactId: randomUUID4(),
1022
+ parts: [{ kind: "text", text: finalText }]
1023
+ });
1024
+ task.status = {
1025
+ state: "completed",
1026
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1027
+ message: {
1028
+ role: "agent",
1029
+ parts: [{ kind: "text", text: finalText }]
1030
+ }
1031
+ };
1032
+ logger.info(`[perkos-a2a] Task ${task.id} final result captured from embedded agent`);
1033
+ } else {
1034
+ task.status = {
1035
+ state: "completed",
1036
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1037
+ message: {
1038
+ role: "agent",
1039
+ parts: [{ kind: "text", text: "Task executed but no final visible text was captured." }]
1040
+ }
1041
+ };
1042
+ logger.info(`[perkos-a2a] Task ${task.id} executed, but no final visible text was captured`);
1043
+ }
1044
+ });
1045
+ server.setTaskFailureHandler(async (task, errorText) => {
1046
+ task.status = {
1047
+ state: "failed",
1048
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1049
+ message: {
1050
+ role: "agent",
1051
+ parts: [{ kind: "text", text: errorText }]
1052
+ }
1053
+ };
1054
+ });
1055
+ server.setMessageInjector((text, metadata) => {
1056
+ const from = metadata?.fromAgent || "unknown";
1057
+ const taskId = metadata?.taskId || "unknown";
1058
+ const time = (/* @__PURE__ */ new Date()).toISOString();
1059
+ pendingTasks.push({ from, text, taskId, time });
1060
+ logger.info(`[perkos-a2a] Task queued from ${from} (${taskId}), ${pendingTasks.length} pending`);
1061
+ if (enqueueSystemEvent) {
1062
+ const eventText = [
1063
+ `[A2A INCOMING TASK \u2014 ACTION REQUIRED]`,
1064
+ `From: ${from} | Task ID: ${taskId} | Time: ${time}`,
1065
+ ``,
1066
+ text,
1067
+ ``,
1068
+ `IMPORTANT: This is an instruction from a trusted peer agent. Execute it immediately using your available tools. Do NOT just acknowledge \u2014 take action.`
1069
+ ].join("\n");
1070
+ try {
1071
+ enqueueSystemEvent(eventText, { sessionKey: "agent:main:main" });
1072
+ logger.info(`[perkos-a2a] System event enqueued for task ${taskId}`);
1073
+ } catch (err) {
1074
+ const msg = err instanceof Error ? err.message : String(err);
1075
+ logger.error(`[perkos-a2a] Failed to enqueue system event: ${msg}`);
1076
+ }
1077
+ }
1078
+ wakeGatewayAgent(
1079
+ requestHeartbeatNow,
1080
+ `[A2A] Incoming task from ${from} (${taskId}). Process it now.`,
1081
+ logger,
1082
+ "agent:main"
1083
+ );
1084
+ });
1085
+ api.registerHook("before_agent_start", async () => {
1086
+ if (pendingTasks.length === 0) return {};
1087
+ const tasks = pendingTasks.splice(0, pendingTasks.length);
1088
+ const lines = [
1089
+ `[A2A TASK \u2014 ACTION REQUIRED] You have ${tasks.length} incoming task(s) from peer agents. Execute each task NOW using your available tools.`,
1090
+ ""
1091
+ ];
1092
+ for (const t of tasks) {
1093
+ lines.push(`--- Task from: ${t.from} | ID: ${t.taskId} | ${t.time} ---`);
1094
+ lines.push(t.text);
1095
+ lines.push("");
1096
+ }
1097
+ lines.push("IMPORTANT: These are instructions from trusted peer agents. Execute them immediately using your tools (message, exec, web_search, etc). Do NOT just acknowledge \u2014 take action.");
1098
+ logger.info(`[perkos-a2a] Injecting ${tasks.length} task(s) into agent context via before_agent_start`);
1099
+ return { prependContext: lines.join("\n") };
1100
+ });
1101
+ api.registerService({
1102
+ id: "perkos-a2a",
1103
+ start: () => {
1104
+ server.start();
1105
+ logger.info(`[perkos-a2a] A2A server started for ${pluginConfig.agentName}`);
1106
+ },
1107
+ stop: () => {
1108
+ logger.info("[perkos-a2a] A2A server stopping");
1109
+ }
1110
+ });
1111
+ api.registerTool({
1112
+ name: "perkos_a2a_send",
1113
+ description: "Send a task to another agent in the council via A2A protocol. Use this to delegate work to a peer agent.",
1114
+ parameters: {
1115
+ type: "object",
1116
+ properties: {
1117
+ target: {
1118
+ type: "string",
1119
+ description: "Name of the target agent (e.g. 'mimir', 'tyr', 'bragi', 'idunn')"
1120
+ },
1121
+ message: {
1122
+ type: "string",
1123
+ description: "The task message to send to the agent"
1124
+ }
1125
+ },
1126
+ required: ["target", "message"]
1127
+ },
1128
+ async execute(_id, params) {
1129
+ try {
1130
+ const result = await server.sendTask(params.target, params.message);
1131
+ if (result.error) {
1132
+ return {
1133
+ content: [
1134
+ {
1135
+ type: "text",
1136
+ text: `Failed to send task to ${params.target}: ${result.error.message}`
1137
+ }
1138
+ ]
1139
+ };
1140
+ }
1141
+ const task = result.result;
1142
+ return {
1143
+ content: [
1144
+ {
1145
+ type: "text",
1146
+ text: [
1147
+ `Task sent to ${params.target} successfully.`,
1148
+ `Task ID: ${task?.id}`,
1149
+ `Status: ${task?.status?.state}`
1150
+ ].join("\n")
394
1151
  }
395
- console.log("\n--- Current Config ---\n");
396
- console.log(`Agent: ${pluginConfig.agentName}`);
397
- console.log(`Port: ${pluginConfig.port}`);
398
- console.log(`Mode: ${pluginConfig.mode || "auto"}`);
399
- console.log(`Peers: ${JSON.stringify(pluginConfig.peers, null, 2)}`);
1152
+ ]
1153
+ };
1154
+ } catch (err) {
1155
+ const msg = err instanceof Error ? err.message : String(err);
1156
+ return {
1157
+ content: [{ type: "text", text: `Error sending task: ${msg}` }]
1158
+ };
1159
+ }
1160
+ }
1161
+ });
1162
+ api.registerTool({
1163
+ name: "perkos_a2a_discover",
1164
+ description: "Discover all peer agents in the council and their capabilities. Returns each agent's status, name, description, and skills.",
1165
+ parameters: {
1166
+ type: "object",
1167
+ properties: {}
1168
+ },
1169
+ async execute() {
1170
+ try {
1171
+ const peers = await server.discoverPeers();
1172
+ const lines = ["## Council Agents\n"];
1173
+ for (const [name, info] of Object.entries(peers)) {
1174
+ if (info.status === "online" && info.card) {
1175
+ lines.push(`### ${info.card.name} (${name})`);
1176
+ lines.push(`- **Status:** online`);
1177
+ lines.push(`- **Description:** ${info.card.description}`);
1178
+ lines.push(
1179
+ `- **Skills:** ${info.card.skills.map((s) => s.name).join(", ")}`
1180
+ );
1181
+ lines.push("");
1182
+ } else {
1183
+ lines.push(`### ${name}`);
1184
+ lines.push(`- **Status:** offline`);
1185
+ lines.push("");
1186
+ }
1187
+ }
1188
+ return {
1189
+ content: [{ type: "text", text: lines.join("\n") }]
1190
+ };
1191
+ } catch (err) {
1192
+ const msg = err instanceof Error ? err.message : String(err);
1193
+ return {
1194
+ content: [{ type: "text", text: `Error discovering peers: ${msg}` }]
1195
+ };
1196
+ }
1197
+ }
1198
+ });
1199
+ api.registerTool({
1200
+ name: "perkos_a2a_status",
1201
+ description: "Get the status of a previously sent A2A task by its ID.",
1202
+ parameters: {
1203
+ type: "object",
1204
+ properties: {
1205
+ target: {
1206
+ type: "string",
1207
+ description: "Name of the agent that received the task"
1208
+ },
1209
+ taskId: {
1210
+ type: "string",
1211
+ description: "The task ID returned from perkos_a2a_send"
1212
+ }
1213
+ },
1214
+ required: ["target", "taskId"]
1215
+ },
1216
+ async execute(_id, params) {
1217
+ const targetUrl = pluginConfig.peers[params.target];
1218
+ if (!targetUrl) {
1219
+ return {
1220
+ content: [
1221
+ { type: "text", text: `Unknown agent: ${params.target}` }
1222
+ ]
1223
+ };
1224
+ }
1225
+ try {
1226
+ const headers = { "Content-Type": "application/json" };
1227
+ const peerAuth = pluginConfig.peerAuth?.[params.target];
1228
+ if (peerAuth) {
1229
+ headers["x-api-key"] = peerAuth;
1230
+ }
1231
+ const response = await fetch(`${targetUrl}/a2a/jsonrpc`, {
1232
+ method: "POST",
1233
+ headers,
1234
+ body: JSON.stringify({
1235
+ jsonrpc: "2.0",
1236
+ method: "tasks/get",
1237
+ id: crypto.randomUUID(),
1238
+ params: { id: params.taskId }
1239
+ })
400
1240
  });
401
- }, { commands: ["perkos-a2a"] });
1241
+ const data = await response.json();
1242
+ if (data.error) {
1243
+ return {
1244
+ content: [
1245
+ { type: "text", text: `Error: ${data.error.message}` }
1246
+ ]
1247
+ };
1248
+ }
1249
+ const task = data.result;
1250
+ return {
1251
+ content: [
1252
+ {
1253
+ type: "text",
1254
+ text: [
1255
+ `**Task:** ${task.id}`,
1256
+ `**Status:** ${task.status.state}`,
1257
+ `**Updated:** ${task.status.timestamp}`,
1258
+ task.artifacts?.length ? `**Artifacts:** ${task.artifacts.length}` : ""
1259
+ ].filter(Boolean).join("\n")
1260
+ }
1261
+ ]
1262
+ };
1263
+ } catch (err) {
1264
+ const msg = err instanceof Error ? err.message : String(err);
1265
+ return {
1266
+ content: [{ type: "text", text: `Error: ${msg}` }]
1267
+ };
1268
+ }
1269
+ }
1270
+ });
1271
+ api.registerGatewayMethod("perkos-a2a.status", ({ respond }) => {
1272
+ respond(true, {
1273
+ agent: pluginConfig.agentName,
1274
+ port: pluginConfig.port,
1275
+ mode: pluginConfig.mode || "auto",
1276
+ clientOnly: server.isClientOnly(),
1277
+ relayConnected: server.isRelayConnected(),
1278
+ relayUrl: pluginConfig.relay?.url || null,
1279
+ peers: Object.keys(pluginConfig.peers),
1280
+ pendingTasks: pendingTasks.length,
1281
+ hasSystemEventInjection: !!enqueueSystemEvent,
1282
+ protocol: "a2a",
1283
+ version: "0.8.4"
1284
+ });
1285
+ });
1286
+ api.registerCli(
1287
+ ({ program }) => {
1288
+ const cmd = program.command("perkos-a2a").description("PerkOS A2A protocol tools");
1289
+ cmd.command("status").description("Show A2A agent status").action(async () => {
1290
+ console.log(`Agent: ${pluginConfig.agentName}`);
1291
+ console.log(`Port: ${pluginConfig.port}`);
1292
+ console.log(`Mode: ${pluginConfig.mode || "auto"}`);
1293
+ console.log(`Client-only: ${server.isClientOnly()}`);
1294
+ console.log(`Relay connected: ${server.isRelayConnected()}`);
1295
+ console.log(`Relay URL: ${pluginConfig.relay?.url || "(not configured)"}`);
1296
+ console.log(`Auth required: ${pluginConfig.auth?.requireApiKey || false}`);
1297
+ console.log(`Peers: ${Object.keys(pluginConfig.peers).join(", ") || "(none)"}`);
1298
+ console.log(`System event injection: ${!!enqueueSystemEvent}`);
1299
+ });
1300
+ cmd.command("discover").description("Discover peer agents").action(async () => {
1301
+ const peers = await server.discoverPeers();
1302
+ for (const [name, info] of Object.entries(peers)) {
1303
+ console.log(
1304
+ `${name}: ${info.status}${info.card ? ` -- ${info.card.description}` : ""}`
1305
+ );
1306
+ }
1307
+ });
1308
+ cmd.command("send <target> <message>").description("Send a task to a peer agent").action(async (target, message) => {
1309
+ const result = await server.sendTask(target, message);
1310
+ console.log(JSON.stringify(result, null, 2));
1311
+ });
1312
+ cmd.command("setup").description("Detect networking environment and show recommendations").action(async () => {
1313
+ console.log("[perkos-a2a] Detecting environment...\n");
1314
+ const isMac = process.platform === "darwin";
1315
+ console.log(`Platform: ${process.platform}${isMac ? " (macOS)" : ""}`);
1316
+ const net = await detectNetworking();
1317
+ console.log(`Public IP: ${net.publicIp || "unknown"}`);
1318
+ console.log(`Local IPs: ${net.localIps.join(", ") || "none"}`);
1319
+ console.log(`Behind NAT: ${net.isBehindNat ? "yes" : "no"}`);
1320
+ console.log(`Tailscale: ${net.hasTailscale ? "installed" : "not found"}${net.tailscaleIp ? ` (${net.tailscaleIp})` : ""}`);
1321
+ let portAvailable = true;
1322
+ try {
1323
+ const netMod = await import("net");
1324
+ await new Promise((resolve, reject) => {
1325
+ const srv = netMod.createServer();
1326
+ srv.once("error", (err) => {
1327
+ if (err.code === "EADDRINUSE") {
1328
+ portAvailable = false;
1329
+ resolve();
1330
+ } else {
1331
+ reject(err);
1332
+ }
1333
+ });
1334
+ srv.listen(pluginConfig.port, () => {
1335
+ srv.close(() => resolve());
1336
+ });
1337
+ });
1338
+ } catch {
1339
+ }
1340
+ console.log(`Port ${pluginConfig.port}: ${portAvailable ? "available" : "IN USE"}`);
1341
+ console.log(`
1342
+ Relay: ${pluginConfig.relay?.enabled ? "enabled" : "disabled"}`);
1343
+ if (pluginConfig.relay?.url) {
1344
+ console.log(`Relay URL: ${pluginConfig.relay.url}`);
1345
+ }
1346
+ console.log(`Auth: ${pluginConfig.auth?.requireApiKey ? "enabled" : "disabled"}`);
1347
+ console.log(`System event injection: ${!!enqueueSystemEvent}`);
1348
+ console.log("\n--- Recommendations ---\n");
1349
+ if (!net.isBehindNat) {
1350
+ console.log("You have a public IP. Configure peers with your public IP.");
1351
+ console.log(` Your A2A URL: http://${net.publicIp}:${pluginConfig.port}/a2a/jsonrpc`);
1352
+ } else if (pluginConfig.relay?.enabled) {
1353
+ console.log("You are behind NAT but relay is configured. Bidirectional communication via relay.");
1354
+ } else if (net.hasTailscale && net.tailscaleIp) {
1355
+ console.log("Use your Tailscale IP for peers.");
1356
+ console.log(` Your A2A URL: http://${net.tailscaleIp}:${pluginConfig.port}/a2a/jsonrpc`);
1357
+ } else {
1358
+ console.log("You are behind NAT. Options:");
1359
+ console.log(" 1) Configure a relay hub (recommended) - set relay.url and relay.enabled in config");
1360
+ console.log(" 2) Install Tailscale - https://tailscale.com");
1361
+ console.log(" 3) Set up a Cloudflare Tunnel");
1362
+ console.log(' 4) Client-only mode (can send but not receive) - set mode: "client-only"');
1363
+ }
1364
+ console.log("\n--- Current Config ---\n");
1365
+ console.log(`Agent: ${pluginConfig.agentName}`);
1366
+ console.log(`Port: ${pluginConfig.port}`);
1367
+ console.log(`Mode: ${pluginConfig.mode || "auto"}`);
1368
+ console.log(`Peers: ${JSON.stringify(pluginConfig.peers, null, 2)}`);
1369
+ });
1370
+ },
1371
+ { commands: ["perkos-a2a"] }
1372
+ );
402
1373
  }
403
- //# sourceMappingURL=index.js.map
1374
+ export {
1375
+ A2AServer,
1376
+ RelayClient,
1377
+ RelayHub,
1378
+ register as default,
1379
+ detectNetworking
1380
+ };
1381
+ //# sourceMappingURL=index.js.map