gopherhole_openclaw_a2a 0.4.7 → 0.5.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 +44 -1
- package/SKILL.md +1 -1
- package/dist/src/channel.js +52 -25
- package/dist/src/connection.d.ts +1 -0
- package/dist/src/connection.js +11 -0
- package/dist/src/gateway-client.d.ts +1 -0
- package/dist/src/gateway-client.js +19 -9
- package/dist/src/types.d.ts +1 -1
- package/openclaw.plugin.json +8 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Then add to your OpenClaw config (`~/.openclaw/openclaw.json`):
|
|
|
13
13
|
```json
|
|
14
14
|
{
|
|
15
15
|
"channels": {
|
|
16
|
-
"
|
|
16
|
+
"gopherhole": {
|
|
17
17
|
"enabled": true,
|
|
18
18
|
"bridgeUrl": "wss://hub.gopherhole.ai/ws",
|
|
19
19
|
"apiKey": "gph_your_api_key_here"
|
|
@@ -28,6 +28,10 @@ Then restart the gateway:
|
|
|
28
28
|
openclaw gateway restart
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
> **Upgrading from 0.4.x?** The channel was renamed from `a2a` to `gopherhole`
|
|
32
|
+
> in 0.5.0. Rename the `channels.a2a` block in your config to
|
|
33
|
+
> `channels.gopherhole`. See [Migration](#migration-from-04x) below.
|
|
34
|
+
|
|
31
35
|
## Getting an API Key
|
|
32
36
|
|
|
33
37
|
1. Go to [gopherhole.ai](https://gopherhole.ai)
|
|
@@ -54,6 +58,45 @@ a2a_agents action=list
|
|
|
54
58
|
a2a_agents action=send agentId=@memory message="store: remember this"
|
|
55
59
|
```
|
|
56
60
|
|
|
61
|
+
## Migration from 0.4.x
|
|
62
|
+
|
|
63
|
+
OpenClaw 2026.9.0 ships its own stock `a2a` channel for the A2A v1.0 protocol,
|
|
64
|
+
which claimed the same channel ID as this plugin. Because the two have
|
|
65
|
+
different config schemas, OpenClaw refuses to start:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
Gateway aborted: config is invalid.
|
|
69
|
+
channels.a2a: invalid config: must not have additional properties:
|
|
70
|
+
"agentId", "agentName", "apiKey", "bridgeUrl", "requestTimeoutMs"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Do not run `openclaw doctor --fix` here** — it repairs the config by deleting
|
|
74
|
+
the unrecognised keys, taking your API key with them.
|
|
75
|
+
|
|
76
|
+
Instead, rename the block in `~/.openclaw/openclaw.json`:
|
|
77
|
+
|
|
78
|
+
```diff
|
|
79
|
+
"channels": {
|
|
80
|
+
- "a2a": {
|
|
81
|
+
+ "gopherhole": {
|
|
82
|
+
"enabled": true,
|
|
83
|
+
"bridgeUrl": "wss://hub.gopherhole.ai/ws",
|
|
84
|
+
"apiKey": "gph_your_api_key_here"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Then restart:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
openclaw gateway restart
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The old `channels.a2a` path is still read as a fallback (with a deprecation
|
|
96
|
+
warning) and will be removed in a future release. Note the fallback only
|
|
97
|
+
applies when OpenClaw's stock `a2a` plugin is disabled — while it is enabled,
|
|
98
|
+
its schema rejects `channels.a2a` before this plugin ever loads.
|
|
99
|
+
|
|
57
100
|
## Links
|
|
58
101
|
|
|
59
102
|
- [GopherHole Hub](https://gopherhole.ai)
|
package/SKILL.md
CHANGED
package/dist/src/channel.js
CHANGED
|
@@ -40,7 +40,21 @@ function loadAgentSecrets(agentId) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
function resolveA2AConfig(cfg) {
|
|
43
|
-
|
|
43
|
+
const channels = cfg?.channels;
|
|
44
|
+
const current = channels?.gopherhole;
|
|
45
|
+
if (current)
|
|
46
|
+
return current;
|
|
47
|
+
// Back-compat: this channel was `a2a` before v0.5.0. OpenClaw now ships its
|
|
48
|
+
// own stock `a2a` channel, so we moved to `gopherhole` to end the clash.
|
|
49
|
+
// Legacy config is still honoured, but only when the stock plugin is
|
|
50
|
+
// disabled -- otherwise its schema rejects channels.a2a before we load.
|
|
51
|
+
const legacy = channels?.a2a;
|
|
52
|
+
if (legacy) {
|
|
53
|
+
a2aLog.error('config_legacy_path', 'channels.a2a is deprecated -- rename it to channels.gopherhole. ' +
|
|
54
|
+
'Support for the old path will be removed in a future release.', {});
|
|
55
|
+
return legacy;
|
|
56
|
+
}
|
|
57
|
+
return {};
|
|
44
58
|
}
|
|
45
59
|
function resolveA2AAccount(opts) {
|
|
46
60
|
const config = resolveA2AConfig(opts.cfg);
|
|
@@ -56,19 +70,19 @@ function resolveA2AAccount(opts) {
|
|
|
56
70
|
};
|
|
57
71
|
}
|
|
58
72
|
const meta = {
|
|
59
|
-
id: '
|
|
60
|
-
label: '
|
|
61
|
-
selectionLabel: '
|
|
62
|
-
detailLabel: 'A2A
|
|
63
|
-
docsPath: '/channels/
|
|
64
|
-
docsLabel: '
|
|
73
|
+
id: 'gopherhole',
|
|
74
|
+
label: 'GopherHole',
|
|
75
|
+
selectionLabel: 'GopherHole (Agent-to-Agent)',
|
|
76
|
+
detailLabel: 'GopherHole A2A',
|
|
77
|
+
docsPath: '/channels/gopherhole',
|
|
78
|
+
docsLabel: 'gopherhole',
|
|
65
79
|
blurb: 'Communicate with other AI agents via GopherHole A2A protocol.',
|
|
66
80
|
systemImage: 'bubble.left.and.bubble.right',
|
|
67
|
-
aliases: ['agent2agent'
|
|
81
|
+
aliases: ['agent2agent'],
|
|
68
82
|
order: 200,
|
|
69
83
|
};
|
|
70
84
|
export const a2aPlugin = {
|
|
71
|
-
id: '
|
|
85
|
+
id: 'gopherhole',
|
|
72
86
|
meta,
|
|
73
87
|
capabilities: {
|
|
74
88
|
chatTypes: ['direct'],
|
|
@@ -78,7 +92,7 @@ export const a2aPlugin = {
|
|
|
78
92
|
unsend: false,
|
|
79
93
|
reply: false,
|
|
80
94
|
},
|
|
81
|
-
reload: { configPrefixes: ['channels.
|
|
95
|
+
reload: { configPrefixes: ['channels.gopherhole'] },
|
|
82
96
|
config: {
|
|
83
97
|
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
84
98
|
resolveAccount: (cfg, accountId) => resolveA2AAccount({ cfg: cfg, accountId }),
|
|
@@ -89,8 +103,8 @@ export const a2aPlugin = {
|
|
|
89
103
|
...next,
|
|
90
104
|
channels: {
|
|
91
105
|
...next.channels,
|
|
92
|
-
|
|
93
|
-
...next.channels?.
|
|
106
|
+
gopherhole: {
|
|
107
|
+
...next.channels?.gopherhole,
|
|
94
108
|
enabled,
|
|
95
109
|
},
|
|
96
110
|
},
|
|
@@ -111,8 +125,8 @@ export const a2aPlugin = {
|
|
|
111
125
|
resolveDmPolicy: ({ account }) => ({
|
|
112
126
|
policy: 'open', // A2A connections are pre-configured, no pairing needed
|
|
113
127
|
allowFrom: [],
|
|
114
|
-
policyPath: 'channels.
|
|
115
|
-
allowFromPath: 'channels.
|
|
128
|
+
policyPath: 'channels.gopherhole.dmPolicy',
|
|
129
|
+
allowFromPath: 'channels.gopherhole.',
|
|
116
130
|
approveHint: '',
|
|
117
131
|
normalizeEntry: (raw) => raw,
|
|
118
132
|
}),
|
|
@@ -141,8 +155,8 @@ export const a2aPlugin = {
|
|
|
141
155
|
...next,
|
|
142
156
|
channels: {
|
|
143
157
|
...next.channels,
|
|
144
|
-
|
|
145
|
-
...next.channels?.
|
|
158
|
+
gopherhole: {
|
|
159
|
+
...next.channels?.gopherhole,
|
|
146
160
|
enabled: true,
|
|
147
161
|
...(input.httpUrl ? { bridgeUrl: input.httpUrl } : {}),
|
|
148
162
|
},
|
|
@@ -165,13 +179,13 @@ export const a2aPlugin = {
|
|
|
165
179
|
},
|
|
166
180
|
sendText: async ({ to, text }) => {
|
|
167
181
|
if (!connectionManager) {
|
|
168
|
-
return { channel: '
|
|
182
|
+
return { channel: 'gopherhole', success: false, error: 'A2A not connected' };
|
|
169
183
|
}
|
|
170
184
|
try {
|
|
171
185
|
const secrets = loadAgentSecrets(to) ?? undefined;
|
|
172
186
|
const response = await connectionManager.sendMessage(to, text, secrets ? { secrets } : undefined);
|
|
173
187
|
return {
|
|
174
|
-
channel: '
|
|
188
|
+
channel: 'gopherhole',
|
|
175
189
|
success: true,
|
|
176
190
|
messageId: response.status,
|
|
177
191
|
response: response.text,
|
|
@@ -179,7 +193,7 @@ export const a2aPlugin = {
|
|
|
179
193
|
}
|
|
180
194
|
catch (err) {
|
|
181
195
|
return {
|
|
182
|
-
channel: '
|
|
196
|
+
channel: 'gopherhole',
|
|
183
197
|
success: false,
|
|
184
198
|
error: err.message,
|
|
185
199
|
};
|
|
@@ -225,7 +239,7 @@ export const a2aPlugin = {
|
|
|
225
239
|
startAccount: async (ctx) => {
|
|
226
240
|
const account = ctx.account;
|
|
227
241
|
const config = account.config;
|
|
228
|
-
ctx.log?.info(`[
|
|
242
|
+
ctx.log?.info(`[gopherhole] Starting A2A channel`);
|
|
229
243
|
ctx.setStatus({ accountId: account.accountId });
|
|
230
244
|
const localConnection = new A2AConnectionManager(config);
|
|
231
245
|
// Defensive: prior plugin versions leaked a connection on every
|
|
@@ -257,11 +271,24 @@ export const a2aPlugin = {
|
|
|
257
271
|
try {
|
|
258
272
|
// Use chat.send to route the message through the agent
|
|
259
273
|
// Session key format: agent:<agentId>:<channel>:<chatId>
|
|
260
|
-
const sessionKey = `agent:main:
|
|
274
|
+
const sessionKey = `agent:main:gopherhole:${message.from}`;
|
|
261
275
|
a2aLog.messageProcessing(message.taskId, sessionKey);
|
|
262
276
|
// Add A2A context so the agent knows to relay its full response
|
|
263
277
|
const a2aContext = `[A2A Request from agent "${message.from}"]\n\n${text}\n\n[Note: Your complete response will be sent back to the requesting agent. Include all relevant information in your reply.]`;
|
|
264
|
-
|
|
278
|
+
// A2A status updates are task-level keepalives. Publish one now and
|
|
279
|
+
// periodically while OpenClaw is working so callers do not mistake
|
|
280
|
+
// a long tool run for a dead connection.
|
|
281
|
+
connectionManager?.sendTaskStatusViaGopherHole(message.taskId, 'OpenClaw is working');
|
|
282
|
+
const keepalive = setInterval(() => {
|
|
283
|
+
connectionManager?.sendTaskStatusViaGopherHole(message.taskId, 'OpenClaw is still working');
|
|
284
|
+
}, 30_000);
|
|
285
|
+
let response;
|
|
286
|
+
try {
|
|
287
|
+
response = await sendChatMessage(sessionKey, a2aContext);
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
clearInterval(keepalive);
|
|
291
|
+
}
|
|
265
292
|
// Log captured response
|
|
266
293
|
if (response?.text) {
|
|
267
294
|
a2aLog.responseCaptured(message.taskId, response.text);
|
|
@@ -292,7 +319,7 @@ export const a2aPlugin = {
|
|
|
292
319
|
running: true,
|
|
293
320
|
lastStartAt: Date.now(),
|
|
294
321
|
});
|
|
295
|
-
ctx.log?.info(`[
|
|
322
|
+
ctx.log?.info(`[gopherhole] A2A channel started`);
|
|
296
323
|
// OpenClaw's channel supervisor treats resolution of this promise as
|
|
297
324
|
// "channel exited" and immediately schedules an auto-restart. Hold the
|
|
298
325
|
// channel open for its lifetime by blocking on the abort signal — the
|
|
@@ -310,12 +337,12 @@ export const a2aPlugin = {
|
|
|
310
337
|
});
|
|
311
338
|
}
|
|
312
339
|
finally {
|
|
313
|
-
ctx.log?.info(`[
|
|
340
|
+
ctx.log?.info(`[gopherhole] Stopping A2A channel`);
|
|
314
341
|
try {
|
|
315
342
|
await localConnection.stop();
|
|
316
343
|
}
|
|
317
344
|
catch (err) {
|
|
318
|
-
ctx.log?.error?.(`[
|
|
345
|
+
ctx.log?.error?.(`[gopherhole] Error during stop: ${err.message}`);
|
|
319
346
|
}
|
|
320
347
|
if (connectionManager === localConnection) {
|
|
321
348
|
connectionManager = null;
|
package/dist/src/connection.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export declare class A2AConnectionManager {
|
|
|
44
44
|
* Uses SDK's respond() method to complete the original task
|
|
45
45
|
*/
|
|
46
46
|
sendResponseViaGopherHole(_targetAgentId: string, taskId: string, text: string, _contextId?: string): void;
|
|
47
|
+
sendTaskStatusViaGopherHole(taskId: string, message?: string): void;
|
|
47
48
|
/**
|
|
48
49
|
* Legacy alias for sendPartsViaGopherHole with text-only
|
|
49
50
|
*/
|
package/dist/src/connection.js
CHANGED
|
@@ -271,6 +271,17 @@ export class A2AConnectionManager {
|
|
|
271
271
|
console.error('[a2a] Error details:', err);
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
|
+
sendTaskStatusViaGopherHole(taskId, message) {
|
|
275
|
+
if (!this.gopherhole || !this.connected || !taskId || taskId.startsWith('gph-')) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
this.gopherhole.updateTaskStatus(taskId, 'working', message);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
console.error('[a2a] Failed to publish task status:', err.message);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
274
285
|
/**
|
|
275
286
|
* Legacy alias for sendPartsViaGopherHole with text-only
|
|
276
287
|
*/
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* resolve when we get state: "final".
|
|
8
8
|
*/
|
|
9
9
|
export declare const GATEWAY_PROTOCOL_VERSION = 4;
|
|
10
|
+
export declare const CHAT_INACTIVITY_TIMEOUT_MS: number;
|
|
10
11
|
export declare function connectToGateway(port?: number): Promise<void>;
|
|
11
12
|
export declare function callGateway(method: string, params: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
|
|
12
13
|
/**
|
|
@@ -20,6 +20,14 @@ let handshakeComplete = false;
|
|
|
20
20
|
// protocol bump fails a focused compatibility test instead of silently
|
|
21
21
|
// breaking inbound A2A replies at runtime.
|
|
22
22
|
export const GATEWAY_PROTOCOL_VERSION = 4;
|
|
23
|
+
export const CHAT_INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000;
|
|
24
|
+
function refreshChatTimeout(runId, pending) {
|
|
25
|
+
clearTimeout(pending.timeout);
|
|
26
|
+
pending.timeout = setTimeout(() => {
|
|
27
|
+
pendingChats.delete(runId);
|
|
28
|
+
pending.reject(new Error('Chat response inactivity timeout'));
|
|
29
|
+
}, pending.timeoutMs);
|
|
30
|
+
}
|
|
23
31
|
function getGatewayToken() {
|
|
24
32
|
// Try the current OpenClaw config location first, then the legacy
|
|
25
33
|
// Clawdbot path as a fallback for users still on older installs.
|
|
@@ -65,7 +73,7 @@ export async function connectToGateway(port = 18789) {
|
|
|
65
73
|
client: {
|
|
66
74
|
id: 'gateway-client',
|
|
67
75
|
displayName: 'A2A Channel Plugin',
|
|
68
|
-
version: '0.4.
|
|
76
|
+
version: '0.4.8',
|
|
69
77
|
platform: process.platform,
|
|
70
78
|
mode: 'backend',
|
|
71
79
|
},
|
|
@@ -174,6 +182,9 @@ function handleChatEvent(payload) {
|
|
|
174
182
|
// Not a chat we're tracking
|
|
175
183
|
return;
|
|
176
184
|
}
|
|
185
|
+
// Any chat event proves the OpenClaw run is alive. Treat the timeout as an
|
|
186
|
+
// inactivity deadline rather than a fixed whole-task deadline.
|
|
187
|
+
refreshChatTimeout(payload.runId, pending);
|
|
177
188
|
// Detailed logging for debugging relay issues
|
|
178
189
|
console.log(`[a2a] Chat event: runId=${payload.runId}, state=${payload.state}, seq=${payload.seq}, role=${payload.message?.role}`);
|
|
179
190
|
if (payload.state === 'delta' || payload.state === 'final') {
|
|
@@ -238,7 +249,7 @@ export async function callGateway(method, params, timeoutMs = 300000) {
|
|
|
238
249
|
* Send a chat message and wait for the full response.
|
|
239
250
|
* chat.send is non-blocking - returns runId immediately, response streams via events.
|
|
240
251
|
*/
|
|
241
|
-
export async function sendChatMessage(sessionKey, message, timeoutMs =
|
|
252
|
+
export async function sendChatMessage(sessionKey, message, timeoutMs = CHAT_INACTIVITY_TIMEOUT_MS) {
|
|
242
253
|
if (!ws || !connected || !handshakeComplete) {
|
|
243
254
|
await connectToGateway();
|
|
244
255
|
}
|
|
@@ -256,16 +267,15 @@ export async function sendChatMessage(sessionKey, message, timeoutMs = 300000) {
|
|
|
256
267
|
}
|
|
257
268
|
// Now wait for chat events to complete
|
|
258
269
|
return new Promise((resolve, reject) => {
|
|
259
|
-
const
|
|
260
|
-
pendingChats.delete(result.runId);
|
|
261
|
-
reject(new Error('Chat response timeout'));
|
|
262
|
-
}, timeoutMs);
|
|
263
|
-
pendingChats.set(result.runId, {
|
|
270
|
+
const pending = {
|
|
264
271
|
resolve,
|
|
265
272
|
reject,
|
|
266
|
-
timeout,
|
|
273
|
+
timeout: undefined,
|
|
267
274
|
latestText: '',
|
|
268
|
-
|
|
275
|
+
timeoutMs,
|
|
276
|
+
};
|
|
277
|
+
pendingChats.set(result.runId, pending);
|
|
278
|
+
refreshChatTimeout(result.runId, pending);
|
|
269
279
|
});
|
|
270
280
|
}
|
|
271
281
|
export function disconnectFromGateway() {
|
package/dist/src/types.d.ts
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "gopherhole_openclaw_a2a",
|
|
3
|
-
"channels": ["
|
|
3
|
+
"channels": ["gopherhole"],
|
|
4
|
+
"contracts": {
|
|
5
|
+
"tools": ["a2a_agents", "a2a_discover_nearby"]
|
|
6
|
+
},
|
|
4
7
|
"configSchema": {
|
|
5
8
|
"type": "object",
|
|
6
9
|
"additionalProperties": false,
|
|
@@ -12,8 +15,8 @@
|
|
|
12
15
|
}
|
|
13
16
|
},
|
|
14
17
|
"channelConfigs": {
|
|
15
|
-
"
|
|
16
|
-
"label": "
|
|
18
|
+
"gopherhole": {
|
|
19
|
+
"label": "GopherHole",
|
|
17
20
|
"description": "Agent-to-agent communication over the GopherHole hub. Lets other AI agents discover and message this OpenClaw instance.",
|
|
18
21
|
"schema": {
|
|
19
22
|
"type": "object",
|
|
@@ -22,7 +25,7 @@
|
|
|
22
25
|
"enabled": {
|
|
23
26
|
"type": "boolean",
|
|
24
27
|
"default": false,
|
|
25
|
-
"description": "Enable the
|
|
28
|
+
"description": "Enable the GopherHole channel"
|
|
26
29
|
},
|
|
27
30
|
"apiKey": {
|
|
28
31
|
"type": "string",
|
|
@@ -84,7 +87,7 @@
|
|
|
84
87
|
},
|
|
85
88
|
"uiHints": {
|
|
86
89
|
"": {
|
|
87
|
-
"label": "
|
|
90
|
+
"label": "GopherHole",
|
|
88
91
|
"help": "Connect this OpenClaw instance to the GopherHole hub so other AI agents can discover and message it. Get an API key at https://gopherhole.ai."
|
|
89
92
|
},
|
|
90
93
|
"apiKey": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gopherhole_openclaw_a2a",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "GopherHole A2A plugin for OpenClaw - connect your AI agent to the GopherHole network",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"license": "MIT",
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@gopherhole/sdk": "^0.
|
|
38
|
+
"@gopherhole/sdk": "^0.9.1",
|
|
39
39
|
"uuid": "^10.0.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|