@agentdeck/bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-code.d.ts +38 -0
- package/dist/adapters/claude-code.d.ts.map +1 -0
- package/dist/adapters/claude-code.js +184 -0
- package/dist/adapters/claude-code.js.map +1 -0
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +18 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/openclaw.d.ts +70 -0
- package/dist/adapters/openclaw.d.ts.map +1 -0
- package/dist/adapters/openclaw.js +664 -0
- package/dist/adapters/openclaw.js.map +1 -0
- package/dist/auth.d.ts +9 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +64 -0
- package/dist/auth.js.map +1 -0
- package/dist/check-deps.d.ts +5 -0
- package/dist/check-deps.d.ts.map +1 -0
- package/dist/check-deps.js +47 -0
- package/dist/check-deps.js.map +1 -0
- package/dist/diag-analyzer.d.ts +29 -0
- package/dist/diag-analyzer.d.ts.map +1 -0
- package/dist/diag-analyzer.js +145 -0
- package/dist/diag-analyzer.js.map +1 -0
- package/dist/event-journal.d.ts +22 -0
- package/dist/event-journal.d.ts.map +1 -0
- package/dist/event-journal.js +117 -0
- package/dist/event-journal.js.map +1 -0
- package/dist/hook-server.d.ts +31 -0
- package/dist/hook-server.d.ts.map +1 -0
- package/dist/hook-server.js +260 -0
- package/dist/hook-server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +796 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +11 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +27 -0
- package/dist/logger.js.map +1 -0
- package/dist/mdns.d.ts +9 -0
- package/dist/mdns.d.ts.map +1 -0
- package/dist/mdns.js +44 -0
- package/dist/mdns.js.map +1 -0
- package/dist/model-catalog.d.ts +29 -0
- package/dist/model-catalog.d.ts.map +1 -0
- package/dist/model-catalog.js +91 -0
- package/dist/model-catalog.js.map +1 -0
- package/dist/output-parser.d.ts +65 -0
- package/dist/output-parser.d.ts.map +1 -0
- package/dist/output-parser.js +1089 -0
- package/dist/output-parser.js.map +1 -0
- package/dist/pty-manager.d.ts +14 -0
- package/dist/pty-manager.d.ts.map +1 -0
- package/dist/pty-manager.js +93 -0
- package/dist/pty-manager.js.map +1 -0
- package/dist/pty-ringbuffer.d.ts +23 -0
- package/dist/pty-ringbuffer.d.ts.map +1 -0
- package/dist/pty-ringbuffer.js +65 -0
- package/dist/pty-ringbuffer.js.map +1 -0
- package/dist/session-registry.d.ts +17 -0
- package/dist/session-registry.d.ts.map +1 -0
- package/dist/session-registry.js +107 -0
- package/dist/session-registry.js.map +1 -0
- package/dist/state-machine.d.ts +48 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +470 -0
- package/dist/state-machine.js.map +1 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-api.d.ts +16 -0
- package/dist/usage-api.d.ts.map +1 -0
- package/dist/usage-api.js +84 -0
- package/dist/usage-api.js.map +1 -0
- package/dist/usage-tracker.d.ts +24 -0
- package/dist/usage-tracker.d.ts.map +1 -0
- package/dist/usage-tracker.js +89 -0
- package/dist/usage-tracker.js.map +1 -0
- package/dist/voice.d.ts +27 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +412 -0
- package/dist/voice.js.map +1 -0
- package/dist/whisper-server-manager.d.ts +19 -0
- package/dist/whisper-server-manager.d.ts.map +1 -0
- package/dist/whisper-server-manager.js +171 -0
- package/dist/whisper-server-manager.js.map +1 -0
- package/dist/ws-server.d.ts +18 -0
- package/dist/ws-server.d.ts.map +1 -0
- package/dist/ws-server.js +86 -0
- package/dist/ws-server.js.map +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { createPublicKey, createPrivateKey, sign as cryptoSign, randomUUID } from 'crypto';
|
|
7
|
+
import WebSocket from 'ws';
|
|
8
|
+
import { debug } from '../logger.js';
|
|
9
|
+
import { OPENCLAW_CAPABILITIES, OPENCLAW_GATEWAY_PORT } from '../types.js';
|
|
10
|
+
import { fetchModelCatalog, getDefaultModelName, invalidateModelCache } from '../model-catalog.js';
|
|
11
|
+
/** Ed25519 SPKI DER prefix length (bytes before the raw 32-byte key) */
|
|
12
|
+
const ED25519_SPKI_PREFIX_LEN = 12;
|
|
13
|
+
/** Protocol version supported by this adapter */
|
|
14
|
+
const PROTOCOL_VERSION = 3;
|
|
15
|
+
/**
|
|
16
|
+
* OpenClaw adapter — connects to OpenClaw Gateway via WebSocket.
|
|
17
|
+
*
|
|
18
|
+
* Protocol: Custom framing (req/res/event), Ed25519 device auth handshake,
|
|
19
|
+
* v3 protocol. Unlike ClaudeCodeAdapter (PTY-based), this adapter:
|
|
20
|
+
* - Connects to an already-running Gateway process
|
|
21
|
+
* - Authenticates via Ed25519 device signature (~/.openclaw/identity/)
|
|
22
|
+
* - Uses req/res framing over WebSocket for all commands
|
|
23
|
+
* - Has no terminal (PTY), mode switching, or diff review
|
|
24
|
+
* - Handles select_option/navigate_option/send_prompt directly via RPC
|
|
25
|
+
*/
|
|
26
|
+
export class OpenClawAdapter extends EventEmitter {
|
|
27
|
+
capabilities = OPENCLAW_CAPABILITIES;
|
|
28
|
+
gatewayUrl;
|
|
29
|
+
ws = null;
|
|
30
|
+
httpServer;
|
|
31
|
+
reqId = 0;
|
|
32
|
+
pendingRpc = new Map();
|
|
33
|
+
reconnectTimer = null;
|
|
34
|
+
projectName = null;
|
|
35
|
+
alive = false;
|
|
36
|
+
shutdownRequested = false;
|
|
37
|
+
diagHandler = null;
|
|
38
|
+
rawDataCallback = null;
|
|
39
|
+
// Session tracking
|
|
40
|
+
currentSessionKey = null;
|
|
41
|
+
currentRunId = null;
|
|
42
|
+
pendingApprovalId = null;
|
|
43
|
+
// Device identity (loaded once on start)
|
|
44
|
+
deviceIdentity = null;
|
|
45
|
+
deviceAuthToken = null;
|
|
46
|
+
/** Reconnect delay (doubles on each failure, max 30s) */
|
|
47
|
+
reconnectDelay = 1000;
|
|
48
|
+
static MAX_RECONNECT_DELAY = 30_000;
|
|
49
|
+
static RPC_TIMEOUT = 10_000;
|
|
50
|
+
constructor(gatewayUrl) {
|
|
51
|
+
super();
|
|
52
|
+
this.gatewayUrl = gatewayUrl || `ws://127.0.0.1:${OPENCLAW_GATEWAY_PORT}`;
|
|
53
|
+
// Create bare HTTP server for WsServer (plugin) attachment.
|
|
54
|
+
this.httpServer = createServer((req, res) => {
|
|
55
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
56
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
57
|
+
res.end(JSON.stringify({
|
|
58
|
+
status: 'ok',
|
|
59
|
+
agent: 'openclaw',
|
|
60
|
+
gateway: this.alive ? 'connected' : 'disconnected',
|
|
61
|
+
session: this.currentSessionKey ?? undefined,
|
|
62
|
+
uptime: process.uptime(),
|
|
63
|
+
}));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (req.method === 'GET' && req.url?.startsWith('/diag')) {
|
|
67
|
+
if (!this.diagHandler) {
|
|
68
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
69
|
+
res.end(JSON.stringify({ error: 'Diagnostic system not initialized' }));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const urlObj = new URL(req.url, `http://localhost`);
|
|
73
|
+
const tail = urlObj.searchParams.get('tail');
|
|
74
|
+
try {
|
|
75
|
+
const dump = this.diagHandler(tail ? parseInt(tail, 10) : undefined);
|
|
76
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
77
|
+
res.end(JSON.stringify(dump));
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
81
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
86
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async start(options) {
|
|
90
|
+
if (options.gatewayUrl) {
|
|
91
|
+
this.gatewayUrl = options.gatewayUrl;
|
|
92
|
+
}
|
|
93
|
+
// Load device identity for Ed25519 auth (non-fatal if missing)
|
|
94
|
+
this.loadDeviceIdentity();
|
|
95
|
+
// Start HTTP server for plugin WebSocket attachment
|
|
96
|
+
await new Promise((resolve, reject) => {
|
|
97
|
+
this.httpServer.on('error', (err) => {
|
|
98
|
+
if (err.code === 'EADDRINUSE') {
|
|
99
|
+
reject(new Error(`Port ${options.port} is already in use.`));
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
reject(err);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
this.httpServer.listen(options.port, '0.0.0.0', () => {
|
|
106
|
+
debug('adapter:openclaw', `HTTP server listening on 0.0.0.0:${options.port}`);
|
|
107
|
+
resolve();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
// Connect to OpenClaw Gateway
|
|
111
|
+
this.connectGateway();
|
|
112
|
+
}
|
|
113
|
+
handleCommand(cmd) {
|
|
114
|
+
switch (cmd.type) {
|
|
115
|
+
case 'respond': {
|
|
116
|
+
debug('adapter:openclaw', `respond: "${cmd.value}"`);
|
|
117
|
+
if (this.pendingApprovalId) {
|
|
118
|
+
const decision = cmd.value === 'y' ? 'allow' : 'deny';
|
|
119
|
+
this.rpcCall('exec.approval.resolve', {
|
|
120
|
+
id: this.pendingApprovalId,
|
|
121
|
+
decision,
|
|
122
|
+
}).catch((err) => {
|
|
123
|
+
debug('adapter:openclaw', `exec.approval.resolve failed: ${err}`);
|
|
124
|
+
});
|
|
125
|
+
this.pendingApprovalId = null;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
case 'select_option': {
|
|
130
|
+
debug('adapter:openclaw', `select_option: idx=${cmd.index}`);
|
|
131
|
+
// Permission prompts: index 0 = Allow, index 1 = Deny
|
|
132
|
+
if (this.pendingApprovalId) {
|
|
133
|
+
const decision = cmd.index === 0 ? 'allow' : 'deny';
|
|
134
|
+
this.rpcCall('exec.approval.resolve', {
|
|
135
|
+
id: this.pendingApprovalId,
|
|
136
|
+
decision,
|
|
137
|
+
}).catch((err) => {
|
|
138
|
+
debug('adapter:openclaw', `exec.approval.resolve failed: ${err}`);
|
|
139
|
+
});
|
|
140
|
+
this.pendingApprovalId = null;
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
case 'navigate_option':
|
|
145
|
+
// OpenClaw doesn't have navigable prompts — no-op but mark as handled
|
|
146
|
+
return true;
|
|
147
|
+
case 'send_prompt': {
|
|
148
|
+
debug('adapter:openclaw', `send_prompt: "${cmd.text.slice(0, 60)}"`);
|
|
149
|
+
if (this.currentSessionKey) {
|
|
150
|
+
this.rpcCall('chat.send', {
|
|
151
|
+
sessionKey: this.currentSessionKey,
|
|
152
|
+
message: cmd.text,
|
|
153
|
+
idempotencyKey: randomUUID(),
|
|
154
|
+
}).catch((err) => {
|
|
155
|
+
debug('adapter:openclaw', `chat.send failed: ${err}`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
debug('adapter:openclaw', 'send_prompt: no active session');
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
case 'interrupt': {
|
|
164
|
+
debug('adapter:openclaw', 'interrupt: sending chat.abort');
|
|
165
|
+
if (this.currentSessionKey) {
|
|
166
|
+
this.rpcCall('chat.abort', {
|
|
167
|
+
sessionKey: this.currentSessionKey,
|
|
168
|
+
...(this.currentRunId ? { runId: this.currentRunId } : {}),
|
|
169
|
+
}).catch((err) => {
|
|
170
|
+
debug('adapter:openclaw', `chat.abort failed: ${err}`);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
case 'escape': {
|
|
176
|
+
debug('adapter:openclaw', 'escape: sending chat.abort');
|
|
177
|
+
if (this.currentSessionKey) {
|
|
178
|
+
this.rpcCall('chat.abort', {
|
|
179
|
+
sessionKey: this.currentSessionKey,
|
|
180
|
+
}).catch((err) => {
|
|
181
|
+
debug('adapter:openclaw', `chat.abort failed: ${err}`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
case 'switch_mode':
|
|
187
|
+
// OpenClaw doesn't have mode switching
|
|
188
|
+
return false;
|
|
189
|
+
case 'voice':
|
|
190
|
+
case 'query_usage':
|
|
191
|
+
// Handled by bridge (VoiceManager, UsageTracker)
|
|
192
|
+
return false;
|
|
193
|
+
default:
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
writeInput(data) {
|
|
198
|
+
// Fallback: send raw text as a chat message
|
|
199
|
+
debug('adapter:openclaw', `writeInput fallback: "${data.slice(0, 60)}"`);
|
|
200
|
+
if (this.currentSessionKey) {
|
|
201
|
+
this.rpcCall('chat.send', {
|
|
202
|
+
sessionKey: this.currentSessionKey,
|
|
203
|
+
message: data,
|
|
204
|
+
idempotencyKey: randomUUID(),
|
|
205
|
+
}).catch((err) => {
|
|
206
|
+
debug('adapter:openclaw', `writeInput RPC failed: ${err}`);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
isAlive() {
|
|
211
|
+
return this.alive;
|
|
212
|
+
}
|
|
213
|
+
attachTerminal(_stdin, _stdout) {
|
|
214
|
+
// No-op: OpenClaw has no PTY terminal
|
|
215
|
+
}
|
|
216
|
+
getTtyPath() {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
getProjectName() {
|
|
220
|
+
return this.projectName;
|
|
221
|
+
}
|
|
222
|
+
getHttpServer() {
|
|
223
|
+
return this.httpServer;
|
|
224
|
+
}
|
|
225
|
+
onDiag(handler) {
|
|
226
|
+
this.diagHandler = handler;
|
|
227
|
+
}
|
|
228
|
+
onRawData(callback) {
|
|
229
|
+
this.rawDataCallback = callback;
|
|
230
|
+
}
|
|
231
|
+
async shutdown() {
|
|
232
|
+
this.shutdownRequested = true;
|
|
233
|
+
if (this.reconnectTimer) {
|
|
234
|
+
clearTimeout(this.reconnectTimer);
|
|
235
|
+
this.reconnectTimer = null;
|
|
236
|
+
}
|
|
237
|
+
for (const [id, pending] of this.pendingRpc) {
|
|
238
|
+
pending.reject(new Error('Adapter shutting down'));
|
|
239
|
+
this.pendingRpc.delete(id);
|
|
240
|
+
}
|
|
241
|
+
if (this.ws) {
|
|
242
|
+
this.ws.close();
|
|
243
|
+
this.ws = null;
|
|
244
|
+
}
|
|
245
|
+
this.alive = false;
|
|
246
|
+
await new Promise((resolve) => {
|
|
247
|
+
this.httpServer.close(() => resolve());
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// ===== Private: Device Identity =====
|
|
251
|
+
loadDeviceIdentity() {
|
|
252
|
+
const identityDir = join(homedir(), '.openclaw', 'identity');
|
|
253
|
+
try {
|
|
254
|
+
const deviceJson = JSON.parse(readFileSync(join(identityDir, 'device.json'), 'utf-8'));
|
|
255
|
+
this.deviceIdentity = {
|
|
256
|
+
deviceId: deviceJson.deviceId,
|
|
257
|
+
publicKeyPem: deviceJson.publicKeyPem,
|
|
258
|
+
privateKeyPem: deviceJson.privateKeyPem,
|
|
259
|
+
};
|
|
260
|
+
const authJson = JSON.parse(readFileSync(join(identityDir, 'device-auth.json'), 'utf-8'));
|
|
261
|
+
const operatorToken = authJson.tokens?.operator;
|
|
262
|
+
if (operatorToken) {
|
|
263
|
+
this.deviceAuthToken = {
|
|
264
|
+
token: operatorToken.token,
|
|
265
|
+
role: operatorToken.role,
|
|
266
|
+
scopes: operatorToken.scopes,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
debug('adapter:openclaw', `Device identity loaded: ${this.deviceIdentity.deviceId.slice(0, 16)}...`);
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
debug('adapter:openclaw', `Device identity not available: ${err}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Extract raw 32-byte Ed25519 public key from PEM → base64url */
|
|
276
|
+
getPublicKeyBase64Url() {
|
|
277
|
+
if (!this.deviceIdentity)
|
|
278
|
+
throw new Error('No device identity');
|
|
279
|
+
const pubKey = createPublicKey(this.deviceIdentity.publicKeyPem);
|
|
280
|
+
const spki = pubKey.export({ type: 'spki', format: 'der' });
|
|
281
|
+
const rawKey = spki.subarray(ED25519_SPKI_PREFIX_LEN);
|
|
282
|
+
return rawKey.toString('base64url');
|
|
283
|
+
}
|
|
284
|
+
/** Build signed device auth for connect request */
|
|
285
|
+
buildDeviceAuth(nonce, requestScopes, authToken) {
|
|
286
|
+
if (!this.deviceIdentity || !this.deviceAuthToken) {
|
|
287
|
+
throw new Error('Device identity or auth token not loaded');
|
|
288
|
+
}
|
|
289
|
+
const signedAt = Date.now();
|
|
290
|
+
const scopes = requestScopes.join(',');
|
|
291
|
+
// v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
|
|
292
|
+
const payload = [
|
|
293
|
+
'v2',
|
|
294
|
+
this.deviceIdentity.deviceId,
|
|
295
|
+
'gateway-client',
|
|
296
|
+
'backend',
|
|
297
|
+
this.deviceAuthToken.role,
|
|
298
|
+
scopes,
|
|
299
|
+
String(signedAt),
|
|
300
|
+
authToken,
|
|
301
|
+
nonce,
|
|
302
|
+
].join('|');
|
|
303
|
+
const privateKey = createPrivateKey(this.deviceIdentity.privateKeyPem);
|
|
304
|
+
const signature = cryptoSign(null, Buffer.from(payload, 'utf8'), privateKey);
|
|
305
|
+
return {
|
|
306
|
+
id: this.deviceIdentity.deviceId,
|
|
307
|
+
publicKey: this.getPublicKeyBase64Url(),
|
|
308
|
+
signature: signature.toString('base64url'),
|
|
309
|
+
signedAt,
|
|
310
|
+
nonce,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
// ===== Private: Gateway WebSocket Connection =====
|
|
314
|
+
connectGateway() {
|
|
315
|
+
if (this.shutdownRequested)
|
|
316
|
+
return;
|
|
317
|
+
debug('adapter:openclaw', `Connecting to Gateway: ${this.gatewayUrl}`);
|
|
318
|
+
try {
|
|
319
|
+
this.ws = new WebSocket(this.gatewayUrl);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
debug('adapter:openclaw', `WebSocket constructor error: ${err}`);
|
|
323
|
+
this.scheduleReconnect();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this.ws.on('open', () => {
|
|
327
|
+
debug('adapter:openclaw', 'WebSocket connected, awaiting connect.challenge...');
|
|
328
|
+
// alive is set only after successful handshake (hello-ok response)
|
|
329
|
+
});
|
|
330
|
+
this.ws.on('message', (data) => {
|
|
331
|
+
const raw = data.toString();
|
|
332
|
+
if (this.rawDataCallback) {
|
|
333
|
+
this.rawDataCallback(raw);
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const msg = JSON.parse(raw);
|
|
337
|
+
this.handleGatewayMessage(msg);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
debug('adapter:openclaw', `Failed to parse gateway message: ${err}`);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
this.ws.on('close', () => {
|
|
344
|
+
debug('adapter:openclaw', 'Gateway disconnected');
|
|
345
|
+
const wasAlive = this.alive;
|
|
346
|
+
this.alive = false;
|
|
347
|
+
if (wasAlive) {
|
|
348
|
+
this.emitAdapterEvent({ source: 'connection', status: 'disconnected' });
|
|
349
|
+
this.emitAdapterEvent({ source: 'hook', event: 'SessionEnd', data: {} });
|
|
350
|
+
}
|
|
351
|
+
for (const [id, pending] of this.pendingRpc) {
|
|
352
|
+
pending.reject(new Error('Gateway disconnected'));
|
|
353
|
+
this.pendingRpc.delete(id);
|
|
354
|
+
}
|
|
355
|
+
this.scheduleReconnect();
|
|
356
|
+
});
|
|
357
|
+
this.ws.on('error', (err) => {
|
|
358
|
+
debug('adapter:openclaw', `Gateway WebSocket error: ${err.message}`);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
scheduleReconnect() {
|
|
362
|
+
if (this.shutdownRequested || this.reconnectTimer)
|
|
363
|
+
return;
|
|
364
|
+
debug('adapter:openclaw', `Reconnecting in ${this.reconnectDelay}ms...`);
|
|
365
|
+
this.reconnectTimer = setTimeout(() => {
|
|
366
|
+
this.reconnectTimer = null;
|
|
367
|
+
this.connectGateway();
|
|
368
|
+
}, this.reconnectDelay);
|
|
369
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, OpenClawAdapter.MAX_RECONNECT_DELAY);
|
|
370
|
+
}
|
|
371
|
+
// ===== Private: Request/Response =====
|
|
372
|
+
rpcCall(method, params) {
|
|
373
|
+
return new Promise((resolve, reject) => {
|
|
374
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
375
|
+
reject(new Error('Gateway not connected'));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const id = `r${++this.reqId}`;
|
|
379
|
+
const message = { type: 'req', id, method, params };
|
|
380
|
+
this.pendingRpc.set(id, { resolve, reject, method });
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
if (this.pendingRpc.has(id)) {
|
|
383
|
+
this.pendingRpc.delete(id);
|
|
384
|
+
reject(new Error(`RPC timeout: ${method} (id=${id})`));
|
|
385
|
+
}
|
|
386
|
+
}, OpenClawAdapter.RPC_TIMEOUT);
|
|
387
|
+
try {
|
|
388
|
+
this.ws.send(JSON.stringify(message));
|
|
389
|
+
debug('adapter:openclaw', `→ ${method} (id=${id})`);
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
this.pendingRpc.delete(id);
|
|
393
|
+
reject(err);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// ===== Private: Gateway Message Handling =====
|
|
398
|
+
handleGatewayMessage(msg) {
|
|
399
|
+
// Response to a request we sent
|
|
400
|
+
if (msg.type === 'res') {
|
|
401
|
+
const pending = this.pendingRpc.get(msg.id);
|
|
402
|
+
if (pending) {
|
|
403
|
+
this.pendingRpc.delete(msg.id);
|
|
404
|
+
if (!msg.ok && msg.error) {
|
|
405
|
+
debug('adapter:openclaw', `RPC error (${pending.method}): ${JSON.stringify(msg.error)}`);
|
|
406
|
+
pending.reject(new Error(msg.error.message || 'RPC error'));
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
debug('adapter:openclaw', `← ${pending.method} (id=${msg.id})`);
|
|
410
|
+
pending.resolve(msg.payload);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
// Unsolicited event from Gateway
|
|
416
|
+
if (msg.type === 'event') {
|
|
417
|
+
// Only emit activity after handshake is complete
|
|
418
|
+
if (this.alive) {
|
|
419
|
+
this.emitAdapterEvent({ source: 'activity' });
|
|
420
|
+
}
|
|
421
|
+
this.handleGatewayEvent(msg.event, msg.payload || {});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
handleGatewayEvent(event, payload) {
|
|
425
|
+
debug('adapter:openclaw', `Event: ${event}`);
|
|
426
|
+
switch (event) {
|
|
427
|
+
// ===== Connection handshake =====
|
|
428
|
+
case 'connect.challenge': {
|
|
429
|
+
const nonce = payload.nonce;
|
|
430
|
+
if (!nonce) {
|
|
431
|
+
debug('adapter:openclaw', 'connect.challenge missing nonce');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
debug('adapter:openclaw', `Challenge nonce: ${nonce.slice(0, 8)}...`);
|
|
435
|
+
this.sendConnectRequest(nonce);
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
// ===== Chat streaming =====
|
|
439
|
+
case 'chat': {
|
|
440
|
+
const state = payload.state;
|
|
441
|
+
const runId = payload.runId;
|
|
442
|
+
const sessionKey = payload.sessionKey;
|
|
443
|
+
// Track active run and session
|
|
444
|
+
if (runId)
|
|
445
|
+
this.currentRunId = runId;
|
|
446
|
+
if (sessionKey)
|
|
447
|
+
this.currentSessionKey = sessionKey;
|
|
448
|
+
switch (state) {
|
|
449
|
+
case 'delta':
|
|
450
|
+
this.emitAdapterEvent({
|
|
451
|
+
source: 'parser',
|
|
452
|
+
event: 'spinner_start',
|
|
453
|
+
data: { runId, sessionKey },
|
|
454
|
+
});
|
|
455
|
+
break;
|
|
456
|
+
case 'final':
|
|
457
|
+
case 'aborted':
|
|
458
|
+
this.currentRunId = null;
|
|
459
|
+
this.emitAdapterEvent({ source: 'parser', event: 'idle' });
|
|
460
|
+
break;
|
|
461
|
+
case 'error':
|
|
462
|
+
this.currentRunId = null;
|
|
463
|
+
debug('adapter:openclaw', `Chat error: ${payload.errorMessage || 'unknown'}`);
|
|
464
|
+
this.emitAdapterEvent({ source: 'parser', event: 'idle' });
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
// ===== Tool approval =====
|
|
470
|
+
case 'exec.approval.requested': {
|
|
471
|
+
const approvalId = payload.id;
|
|
472
|
+
const command = payload.command;
|
|
473
|
+
const ask = payload.ask;
|
|
474
|
+
this.pendingApprovalId = approvalId;
|
|
475
|
+
this.emitAdapterEvent({
|
|
476
|
+
source: 'parser',
|
|
477
|
+
event: 'permission_prompt',
|
|
478
|
+
data: {
|
|
479
|
+
question: ask || command || 'Approve tool execution?',
|
|
480
|
+
options: [
|
|
481
|
+
{ index: 0, label: 'Allow', shortcut: 'y' },
|
|
482
|
+
{ index: 1, label: 'Deny', shortcut: 'n' },
|
|
483
|
+
],
|
|
484
|
+
navigable: false,
|
|
485
|
+
cursorIndex: 0,
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
case 'exec.approval.resolved':
|
|
491
|
+
// Approval resolved (by us or another client) → processing continues
|
|
492
|
+
this.pendingApprovalId = null;
|
|
493
|
+
this.emitAdapterEvent({ source: 'parser', event: 'spinner_start' });
|
|
494
|
+
break;
|
|
495
|
+
// ===== Presence / keepalive =====
|
|
496
|
+
case 'presence':
|
|
497
|
+
case 'tick':
|
|
498
|
+
// Activity already emitted in handleGatewayMessage
|
|
499
|
+
break;
|
|
500
|
+
// ===== Lifecycle =====
|
|
501
|
+
case 'shutdown':
|
|
502
|
+
debug('adapter:openclaw', 'Gateway shutdown event');
|
|
503
|
+
this.emitAdapterEvent({ source: 'hook', event: 'SessionEnd', data: {} });
|
|
504
|
+
break;
|
|
505
|
+
default:
|
|
506
|
+
debug('adapter:openclaw', `Unhandled event: ${event}`);
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Send `connect` request with Ed25519 device authentication.
|
|
512
|
+
* Called when Gateway sends `connect.challenge` event with nonce.
|
|
513
|
+
*/
|
|
514
|
+
sendConnectRequest(nonce) {
|
|
515
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
516
|
+
return;
|
|
517
|
+
// Scopes must match between signed payload and request params — Gateway
|
|
518
|
+
// reconstructs the payload from request params for signature verification.
|
|
519
|
+
const scopes = this.deviceAuthToken?.scopes
|
|
520
|
+
?? ['operator.admin', 'operator.approvals', 'operator.read'];
|
|
521
|
+
const authToken = this.deviceAuthToken?.token ?? '';
|
|
522
|
+
const params = {
|
|
523
|
+
minProtocol: PROTOCOL_VERSION,
|
|
524
|
+
maxProtocol: PROTOCOL_VERSION,
|
|
525
|
+
client: {
|
|
526
|
+
id: 'gateway-client',
|
|
527
|
+
displayName: 'AgentDeck',
|
|
528
|
+
version: '0.3.0',
|
|
529
|
+
platform: process.platform,
|
|
530
|
+
mode: 'backend',
|
|
531
|
+
},
|
|
532
|
+
role: 'operator',
|
|
533
|
+
scopes,
|
|
534
|
+
caps: ['tool-events'],
|
|
535
|
+
};
|
|
536
|
+
// Add device auth if identity is available
|
|
537
|
+
if (this.deviceIdentity && this.deviceAuthToken) {
|
|
538
|
+
try {
|
|
539
|
+
params.device = this.buildDeviceAuth(nonce, scopes, authToken);
|
|
540
|
+
params.auth = { token: authToken };
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
debug('adapter:openclaw', `Device auth signing failed: ${err}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const id = 'init-1';
|
|
547
|
+
const message = { type: 'req', id, method: 'connect', params };
|
|
548
|
+
this.pendingRpc.set(id, {
|
|
549
|
+
resolve: (payload) => {
|
|
550
|
+
debug('adapter:openclaw', 'Handshake complete (hello-ok)');
|
|
551
|
+
this.alive = true;
|
|
552
|
+
this.reconnectDelay = 1000;
|
|
553
|
+
this.emitAdapterEvent({ source: 'connection', status: 'connected' });
|
|
554
|
+
this.emitAdapterEvent({ source: 'hook', event: 'SessionStart', data: {} });
|
|
555
|
+
// Log available features
|
|
556
|
+
if (payload && typeof payload === 'object') {
|
|
557
|
+
const p = payload;
|
|
558
|
+
const features = p.features;
|
|
559
|
+
if (features) {
|
|
560
|
+
const methods = features.methods;
|
|
561
|
+
const events = features.events;
|
|
562
|
+
debug('adapter:openclaw', `Gateway features: ${methods?.length || 0} methods, ${events?.length || 0} events`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Fetch sessions (async, non-blocking)
|
|
566
|
+
this.fetchSessions();
|
|
567
|
+
// Fetch model catalog (async, non-blocking)
|
|
568
|
+
this.emitModelCatalog();
|
|
569
|
+
},
|
|
570
|
+
reject: (err) => {
|
|
571
|
+
debug('adapter:openclaw', `Handshake failed: ${err.message}`);
|
|
572
|
+
// WebSocket will close → reconnect
|
|
573
|
+
},
|
|
574
|
+
method: 'connect',
|
|
575
|
+
});
|
|
576
|
+
// Timeout for handshake
|
|
577
|
+
setTimeout(() => {
|
|
578
|
+
if (this.pendingRpc.has(id)) {
|
|
579
|
+
this.pendingRpc.delete(id);
|
|
580
|
+
debug('adapter:openclaw', 'Connect handshake timeout');
|
|
581
|
+
}
|
|
582
|
+
}, OpenClawAdapter.RPC_TIMEOUT);
|
|
583
|
+
try {
|
|
584
|
+
this.ws.send(JSON.stringify(message));
|
|
585
|
+
debug('adapter:openclaw', '→ connect (handshake)');
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
this.pendingRpc.delete(id);
|
|
589
|
+
debug('adapter:openclaw', `Failed to send connect request: ${err}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/** Fetch sessions and set the most recently updated one as active. */
|
|
593
|
+
async fetchSessions() {
|
|
594
|
+
try {
|
|
595
|
+
const result = await this.rpcCall('sessions.list', {});
|
|
596
|
+
if (!result || typeof result !== 'object')
|
|
597
|
+
return;
|
|
598
|
+
const resp = result;
|
|
599
|
+
const sessions = resp.sessions;
|
|
600
|
+
if (!sessions || sessions.length === 0) {
|
|
601
|
+
debug('adapter:openclaw', 'No sessions available');
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
debug('adapter:openclaw', `Sessions: ${sessions.length}`);
|
|
605
|
+
// Pick the most recently updated session
|
|
606
|
+
const sorted = [...sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
607
|
+
this.currentSessionKey = sorted[0].key;
|
|
608
|
+
const label = sorted[0].label || sorted[0].displayName;
|
|
609
|
+
if (label) {
|
|
610
|
+
this.projectName = label;
|
|
611
|
+
this.emitAdapterEvent({
|
|
612
|
+
source: 'parser',
|
|
613
|
+
event: 'project_name',
|
|
614
|
+
data: { name: label },
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
debug('adapter:openclaw', `Active session: ${this.currentSessionKey}`);
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
debug('adapter:openclaw', `sessions.list failed: ${err}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/** Fetch model catalog via CLI and emit events. Retries once on failure. */
|
|
624
|
+
emitModelCatalog(retry = true) {
|
|
625
|
+
// Invalidate cache on reconnect to get fresh data
|
|
626
|
+
invalidateModelCache();
|
|
627
|
+
try {
|
|
628
|
+
const catalog = fetchModelCatalog();
|
|
629
|
+
if (!catalog) {
|
|
630
|
+
if (retry && this.alive) {
|
|
631
|
+
debug('adapter:openclaw', 'Model catalog empty — retrying in 10s');
|
|
632
|
+
setTimeout(() => this.emitModelCatalog(false), 10_000);
|
|
633
|
+
}
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// Emit model_info for default model name (StateMachine uses this)
|
|
637
|
+
const defaultModel = getDefaultModelName();
|
|
638
|
+
if (defaultModel) {
|
|
639
|
+
this.emitAdapterEvent({
|
|
640
|
+
source: 'parser',
|
|
641
|
+
event: 'model_info',
|
|
642
|
+
data: { model: defaultModel, plan: null },
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
// Emit model_catalog metadata for the full list
|
|
646
|
+
this.emitAdapterEvent({
|
|
647
|
+
source: 'metadata',
|
|
648
|
+
event: 'model_catalog',
|
|
649
|
+
data: { models: catalog.entries },
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
debug('adapter:openclaw', `Model catalog fetch failed: ${err}`);
|
|
654
|
+
if (retry && this.alive) {
|
|
655
|
+
debug('adapter:openclaw', 'Retrying model catalog in 10s');
|
|
656
|
+
setTimeout(() => this.emitModelCatalog(false), 10_000);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
emitAdapterEvent(evt) {
|
|
661
|
+
this.emit('event', evt);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
//# sourceMappingURL=openclaw.js.map
|