@astroanywhere/agent 0.1.35 → 0.1.37
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/cli.js +8 -11
- package/dist/cli.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +71 -51
- package/dist/commands/start.js.map +1 -1
- package/dist/lib/display.d.ts +24 -0
- package/dist/lib/display.d.ts.map +1 -0
- package/dist/lib/display.js +202 -0
- package/dist/lib/display.js.map +1 -0
- package/dist/lib/openclaw-bridge.d.ts +73 -0
- package/dist/lib/openclaw-bridge.d.ts.map +1 -0
- package/dist/lib/openclaw-bridge.js +457 -0
- package/dist/lib/openclaw-bridge.js.map +1 -0
- package/dist/lib/providers.d.ts.map +1 -1
- package/dist/lib/providers.js +46 -53
- package/dist/lib/providers.js.map +1 -1
- package/dist/lib/ssh-installer.d.ts +19 -10
- package/dist/lib/ssh-installer.d.ts.map +1 -1
- package/dist/lib/ssh-installer.js +18 -12
- package/dist/lib/ssh-installer.js.map +1 -1
- package/dist/lib/task-executor.d.ts +8 -1
- package/dist/lib/task-executor.d.ts.map +1 -1
- package/dist/lib/task-executor.js +77 -4
- package/dist/lib/task-executor.js.map +1 -1
- package/dist/lib/websocket-client.d.ts +5 -0
- package/dist/lib/websocket-client.d.ts.map +1 -1
- package/dist/lib/websocket-client.js +159 -0
- package/dist/lib/websocket-client.js.map +1 -1
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/providers/claude-sdk-adapter.d.ts +4 -1
- package/dist/providers/claude-sdk-adapter.d.ts.map +1 -1
- package/dist/providers/claude-sdk-adapter.js +16 -5
- package/dist/providers/claude-sdk-adapter.js.map +1 -1
- package/dist/providers/openclaw-adapter.d.ts +46 -29
- package/dist/providers/openclaw-adapter.d.ts.map +1 -1
- package/dist/providers/openclaw-adapter.js +603 -215
- package/dist/providers/openclaw-adapter.js.map +1 -1
- package/dist/providers/opencode-adapter.d.ts +29 -0
- package/dist/providers/opencode-adapter.d.ts.map +1 -1
- package/dist/providers/opencode-adapter.js +145 -38
- package/dist/providers/opencode-adapter.js.map +1 -1
- package/dist/types.d.ts +83 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,61 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenClaw provider adapter
|
|
2
|
+
* OpenClaw provider adapter — Gateway WebSocket mode
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Connects to the local OpenClaw gateway via WebSocket and dispatches tasks
|
|
5
|
+
* using `chat.send`. Each task gets its own session key for isolation.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
* openclaw
|
|
7
|
+
* Gateway discovery:
|
|
8
|
+
* 1. Read ~/.openclaw/openclaw.json for gateway port + auth token
|
|
9
|
+
* 2. Probe ws://127.0.0.1:{port} for connect.challenge
|
|
10
|
+
* 3. Handshake with client.id='gateway-client', mode='backend'
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
+
* Execution flow:
|
|
13
|
+
* chat.send({ sessionKey, message, idempotencyKey })
|
|
14
|
+
* → gateway streams `agent` + `chat` events over WebSocket
|
|
15
|
+
* → adapter translates to TaskOutputStream calls
|
|
16
|
+
* → returns TaskResult on session completion
|
|
12
17
|
*/
|
|
13
|
-
import { spawn } from 'node:child_process';
|
|
14
18
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
19
|
import { join } from 'node:path';
|
|
16
20
|
import { homedir } from 'node:os';
|
|
17
|
-
import {
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import WebSocket from 'ws';
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const PROTOCOL_VERSION = 3;
|
|
27
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
28
|
+
/** TTL for preserved sessions (30 minutes) */
|
|
29
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
18
30
|
export class OpenClawAdapter {
|
|
19
31
|
type = 'openclaw';
|
|
20
32
|
name = 'OpenClaw';
|
|
21
33
|
activeTasks = 0;
|
|
22
|
-
maxTasks =
|
|
34
|
+
maxTasks = 10;
|
|
23
35
|
lastError;
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
gatewayConfig = null;
|
|
37
|
+
lastAvailableCheck = null;
|
|
38
|
+
/** Preserved sessions for multi-turn resume, keyed by taskId */
|
|
39
|
+
preservedSessions = new Map();
|
|
26
40
|
async isAvailable() {
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Read the default model from ~/.openclaw/config.json
|
|
37
|
-
*/
|
|
38
|
-
readConfigModel() {
|
|
41
|
+
const config = this.readGatewayConfig();
|
|
42
|
+
if (!config)
|
|
43
|
+
return false;
|
|
44
|
+
this.gatewayConfig = config;
|
|
45
|
+
// Probe the gateway with a quick connect
|
|
39
46
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const content = readFileSync(configPath, 'utf-8');
|
|
44
|
-
const config = JSON.parse(content);
|
|
45
|
-
return config.model ?? null;
|
|
47
|
+
const ok = await this.probeGateway(config);
|
|
48
|
+
this.lastAvailableCheck = { available: ok, at: Date.now() };
|
|
49
|
+
return ok;
|
|
46
50
|
}
|
|
47
51
|
catch {
|
|
48
|
-
|
|
52
|
+
this.lastAvailableCheck = { available: false, at: Date.now() };
|
|
53
|
+
return false;
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
async execute(task, stream, signal) {
|
|
52
|
-
if (!this.
|
|
57
|
+
if (!this.gatewayConfig) {
|
|
53
58
|
const available = await this.isAvailable();
|
|
54
59
|
if (!available) {
|
|
55
60
|
return {
|
|
56
61
|
taskId: task.id,
|
|
57
62
|
status: 'failed',
|
|
58
|
-
error: 'OpenClaw not available',
|
|
63
|
+
error: 'OpenClaw gateway not available',
|
|
59
64
|
startedAt: new Date().toISOString(),
|
|
60
65
|
completedAt: new Date().toISOString(),
|
|
61
66
|
};
|
|
@@ -64,12 +69,23 @@ export class OpenClawAdapter {
|
|
|
64
69
|
this.activeTasks++;
|
|
65
70
|
const startedAt = new Date().toISOString();
|
|
66
71
|
try {
|
|
67
|
-
stream.status('running', 0, '
|
|
68
|
-
const
|
|
72
|
+
stream.status('running', 0, 'Connecting to OpenClaw gateway');
|
|
73
|
+
const sessionKey = `astro:task:${task.id}`;
|
|
74
|
+
const result = await this.runViaGateway(task, stream, signal);
|
|
75
|
+
const isCancelled = signal.aborted || result.error === 'Task cancelled';
|
|
76
|
+
// Preserve session for multi-turn resume (unless cancelled/failed)
|
|
77
|
+
if (!isCancelled && !result.error) {
|
|
78
|
+
this.cleanupExpiredSessions();
|
|
79
|
+
this.preservedSessions.set(task.id, {
|
|
80
|
+
sessionKey,
|
|
81
|
+
taskId: task.id,
|
|
82
|
+
workingDirectory: task.workingDirectory,
|
|
83
|
+
createdAt: Date.now(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
69
86
|
return {
|
|
70
87
|
taskId: task.id,
|
|
71
|
-
status: result.
|
|
72
|
-
exitCode: result.exitCode,
|
|
88
|
+
status: isCancelled ? 'cancelled' : result.error ? 'failed' : 'completed',
|
|
73
89
|
output: result.output,
|
|
74
90
|
error: result.error,
|
|
75
91
|
startedAt,
|
|
@@ -103,226 +119,598 @@ export class OpenClawAdapter {
|
|
|
103
119
|
}
|
|
104
120
|
}
|
|
105
121
|
async getStatus() {
|
|
106
|
-
|
|
107
|
-
|
|
122
|
+
// Use cached availability if checked within the last 30 seconds to avoid
|
|
123
|
+
// opening a new WebSocket probe on every status poll
|
|
124
|
+
let available;
|
|
125
|
+
if (this.lastAvailableCheck && Date.now() - this.lastAvailableCheck.at < 30_000) {
|
|
126
|
+
available = this.lastAvailableCheck.available;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
available = await this.isAvailable();
|
|
130
|
+
}
|
|
108
131
|
return {
|
|
109
132
|
available,
|
|
110
|
-
version:
|
|
133
|
+
version: null,
|
|
111
134
|
activeTasks: this.activeTasks,
|
|
112
135
|
maxTasks: this.maxTasks,
|
|
113
136
|
lastError: this.lastError,
|
|
114
137
|
};
|
|
115
138
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
'agent',
|
|
128
|
-
'--mode', 'rpc',
|
|
129
|
-
'--json',
|
|
130
|
-
...(model ? ['--model', model] : []),
|
|
131
|
-
'--prompt', effectivePrompt,
|
|
132
|
-
];
|
|
133
|
-
const env = {
|
|
134
|
-
...process.env,
|
|
135
|
-
...task.environment,
|
|
136
|
-
};
|
|
137
|
-
// Validate working directory exists before spawning
|
|
138
|
-
if (task.workingDirectory && !existsSync(task.workingDirectory)) {
|
|
139
|
-
reject(new Error(`Working directory does not exist: ${task.workingDirectory}. ` +
|
|
140
|
-
`Ensure the directory exists on this machine before dispatching.`));
|
|
141
|
-
return;
|
|
139
|
+
// ─── Multi-Turn Resume ─────────────────────────────────────────
|
|
140
|
+
/**
|
|
141
|
+
* Resume a completed session by sending another chat.send to the same sessionKey.
|
|
142
|
+
* The OpenClaw gateway preserves session history per sessionKey.
|
|
143
|
+
*/
|
|
144
|
+
async resumeTask(taskId, message, _workingDirectory, sessionId, stream, signal) {
|
|
145
|
+
if (!this.gatewayConfig) {
|
|
146
|
+
// Attempt availability check as fallback (mirrors execute() pattern)
|
|
147
|
+
const available = await this.isAvailable();
|
|
148
|
+
if (!available || !this.gatewayConfig) {
|
|
149
|
+
return { success: false, output: '', error: 'OpenClaw gateway not available' };
|
|
142
150
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
}
|
|
152
|
+
// Use the preserved session key, or construct one from the original taskId (keyed by taskId)
|
|
153
|
+
const session = this.preservedSessions.get(taskId);
|
|
154
|
+
const sessionKey = session?.sessionKey || `astro:task:${sessionId}`;
|
|
155
|
+
this.activeTasks++;
|
|
156
|
+
let ws;
|
|
157
|
+
try {
|
|
158
|
+
ws = await this.connectToGateway(this.gatewayConfig);
|
|
159
|
+
stream.status('running', 5, 'Resuming OpenClaw session');
|
|
160
|
+
// sendChatMessage() registers ws error/close handlers before sending,
|
|
161
|
+
// so it owns cleanup (calls ws.close() in its finish() helper)
|
|
162
|
+
const result = await this.sendChatMessage(ws, sessionKey, message, stream, signal);
|
|
163
|
+
ws = undefined;
|
|
164
|
+
// Update preserved session timestamp
|
|
165
|
+
if (session) {
|
|
166
|
+
session.createdAt = Date.now();
|
|
150
167
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
168
|
+
return {
|
|
169
|
+
success: !result.error,
|
|
170
|
+
output: result.output,
|
|
171
|
+
error: result.error,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
ws?.close();
|
|
176
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
177
|
+
this.lastError = errorMsg;
|
|
178
|
+
return { success: false, output: '', error: errorMsg };
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
this.activeTasks--;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Mid-execution message injection is not supported for OpenClaw gateway.
|
|
186
|
+
* The gateway processes one chat.send at a time per session.
|
|
187
|
+
*/
|
|
188
|
+
async injectMessage(_taskId, _content, _interrupt) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Get preserved session context for a task (used by task executor for resume routing).
|
|
193
|
+
*/
|
|
194
|
+
getTaskContext(taskId) {
|
|
195
|
+
this.cleanupExpiredSessions();
|
|
196
|
+
const session = this.preservedSessions.get(taskId);
|
|
197
|
+
if (!session || Date.now() - session.createdAt > SESSION_TTL_MS) {
|
|
198
|
+
this.preservedSessions.delete(taskId);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
sessionId: session.sessionKey,
|
|
203
|
+
workingDirectory: session.workingDirectory || '',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
cleanupExpiredSessions() {
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
for (const [key, session] of this.preservedSessions) {
|
|
209
|
+
if (now - session.createdAt > SESSION_TTL_MS) {
|
|
210
|
+
this.preservedSessions.delete(key);
|
|
154
211
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// ─── Gateway Config Discovery ────────────────────────────────────
|
|
215
|
+
readGatewayConfig() {
|
|
216
|
+
try {
|
|
217
|
+
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
218
|
+
if (!existsSync(configPath))
|
|
219
|
+
return null;
|
|
220
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
221
|
+
const port = raw?.gateway?.port;
|
|
222
|
+
if (!port)
|
|
223
|
+
return null;
|
|
224
|
+
const token = raw?.gateway?.auth?.token || '';
|
|
225
|
+
const bind = raw?.gateway?.bind || '127.0.0.1';
|
|
226
|
+
const host = bind === 'loopback' || bind === '127.0.0.1' ? '127.0.0.1' : bind;
|
|
227
|
+
return {
|
|
228
|
+
port,
|
|
229
|
+
token,
|
|
230
|
+
url: `ws://${host}:${port}`,
|
|
166
231
|
};
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// ─── Gateway Probe ───────────────────────────────────────────────
|
|
238
|
+
probeGateway(config) {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
let resolved = false;
|
|
241
|
+
const done = (val) => { if (!resolved) {
|
|
242
|
+
resolved = true;
|
|
243
|
+
resolve(val);
|
|
244
|
+
} };
|
|
245
|
+
let ws;
|
|
246
|
+
const timeout = setTimeout(() => {
|
|
247
|
+
ws?.removeAllListeners();
|
|
248
|
+
ws?.close();
|
|
249
|
+
done(false);
|
|
250
|
+
}, 5000);
|
|
251
|
+
ws = new WebSocket(config.url);
|
|
252
|
+
ws.on('message', (data) => {
|
|
253
|
+
try {
|
|
254
|
+
const frame = JSON.parse(String(data));
|
|
255
|
+
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
|
256
|
+
clearTimeout(timeout);
|
|
257
|
+
ws.removeAllListeners();
|
|
258
|
+
ws.close();
|
|
259
|
+
done(true);
|
|
182
260
|
}
|
|
183
261
|
}
|
|
262
|
+
catch {
|
|
263
|
+
// ignore
|
|
264
|
+
}
|
|
184
265
|
});
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
266
|
+
ws.on('error', () => {
|
|
267
|
+
clearTimeout(timeout);
|
|
268
|
+
ws?.removeAllListeners();
|
|
269
|
+
done(false);
|
|
189
270
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
271
|
+
ws.on('close', () => {
|
|
272
|
+
clearTimeout(timeout);
|
|
273
|
+
done(false);
|
|
193
274
|
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// ─── Gateway Connection ──────────────────────────────────────────
|
|
278
|
+
connectToGateway(config) {
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
let ws;
|
|
281
|
+
const timeout = setTimeout(() => {
|
|
282
|
+
ws?.removeAllListeners();
|
|
283
|
+
ws?.close();
|
|
284
|
+
reject(new Error('Gateway connection timeout'));
|
|
285
|
+
}, CONNECT_TIMEOUT_MS);
|
|
286
|
+
ws = new WebSocket(config.url);
|
|
287
|
+
const handshakeHandler = (data) => {
|
|
288
|
+
try {
|
|
289
|
+
const frame = JSON.parse(String(data));
|
|
290
|
+
// Step 1: Receive challenge, send connect
|
|
291
|
+
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
|
292
|
+
ws.send(JSON.stringify({
|
|
293
|
+
type: 'req',
|
|
294
|
+
id: 'connect-1',
|
|
295
|
+
method: 'connect',
|
|
296
|
+
params: {
|
|
297
|
+
minProtocol: PROTOCOL_VERSION,
|
|
298
|
+
maxProtocol: PROTOCOL_VERSION,
|
|
299
|
+
client: {
|
|
300
|
+
id: 'gateway-client',
|
|
301
|
+
version: 'dev',
|
|
302
|
+
platform: process.platform,
|
|
303
|
+
mode: 'backend',
|
|
304
|
+
},
|
|
305
|
+
caps: ['tool-events'],
|
|
306
|
+
auth: { token: config.token },
|
|
307
|
+
role: 'operator',
|
|
308
|
+
scopes: ['operator.read', 'operator.write'],
|
|
309
|
+
},
|
|
310
|
+
}));
|
|
200
311
|
}
|
|
312
|
+
// Step 2: Receive connect response
|
|
313
|
+
if (frame.type === 'res' && frame.id === 'connect-1') {
|
|
314
|
+
clearTimeout(timeout);
|
|
315
|
+
ws.removeListener('message', handshakeHandler);
|
|
316
|
+
ws.removeListener('error', errorHandler);
|
|
317
|
+
if (frame.ok) {
|
|
318
|
+
resolve(ws);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
ws.close();
|
|
322
|
+
reject(new Error(`Gateway handshake failed: ${frame.error?.message || 'unknown'}`));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// ignore parse errors during handshake
|
|
201
328
|
}
|
|
329
|
+
};
|
|
330
|
+
const errorHandler = (err) => {
|
|
331
|
+
clearTimeout(timeout);
|
|
332
|
+
ws?.removeListener('message', handshakeHandler);
|
|
333
|
+
reject(err);
|
|
334
|
+
};
|
|
335
|
+
ws.on('message', handshakeHandler);
|
|
336
|
+
ws.on('error', errorHandler);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
// ─── Task Execution via Gateway ──────────────────────────────────
|
|
340
|
+
async runViaGateway(task, stream, signal) {
|
|
341
|
+
const ws = await this.connectToGateway(this.gatewayConfig);
|
|
342
|
+
stream.status('running', 5, 'Connected to gateway');
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
const sessionKey = `astro:task:${task.id}`;
|
|
345
|
+
const idempotencyKey = randomUUID();
|
|
346
|
+
const artifacts = [];
|
|
347
|
+
let outputText = '';
|
|
348
|
+
let lastMetrics;
|
|
349
|
+
let runId;
|
|
350
|
+
let finished = false;
|
|
351
|
+
let lifecycleEnded = false;
|
|
352
|
+
let chatFinalReceived = false;
|
|
353
|
+
let gracePeriodTimeout;
|
|
354
|
+
const finish = (error) => {
|
|
355
|
+
if (finished)
|
|
356
|
+
return;
|
|
357
|
+
finished = true;
|
|
202
358
|
signal.removeEventListener('abort', abortHandler);
|
|
359
|
+
if (taskTimeout)
|
|
360
|
+
clearTimeout(taskTimeout);
|
|
361
|
+
if (gracePeriodTimeout)
|
|
362
|
+
clearTimeout(gracePeriodTimeout);
|
|
363
|
+
ws.removeAllListeners();
|
|
364
|
+
ws.close();
|
|
203
365
|
resolve({
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
error: stderr || undefined,
|
|
366
|
+
output: outputText,
|
|
367
|
+
error,
|
|
207
368
|
artifacts: artifacts.length > 0 ? artifacts : undefined,
|
|
208
369
|
metrics: lastMetrics,
|
|
209
370
|
});
|
|
210
|
-
}
|
|
371
|
+
};
|
|
372
|
+
/** Finish when both lifecycle.end and chat.final have been seen, or
|
|
373
|
+
* after a short grace period if only lifecycle.end arrived. */
|
|
374
|
+
const tryFinishAfterLifecycle = () => {
|
|
375
|
+
if (chatFinalReceived) {
|
|
376
|
+
finish();
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
// Grace period: if chat.final doesn't arrive within 500ms, finish anyway
|
|
380
|
+
gracePeriodTimeout = setTimeout(() => { if (!finished)
|
|
381
|
+
finish(); }, 500);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
// Handle abort
|
|
385
|
+
const abortHandler = () => {
|
|
386
|
+
if (runId) {
|
|
387
|
+
// Try to abort the chat
|
|
388
|
+
try {
|
|
389
|
+
ws.send(JSON.stringify({
|
|
390
|
+
type: 'req',
|
|
391
|
+
id: 'abort-1',
|
|
392
|
+
method: 'chat.abort',
|
|
393
|
+
params: { sessionKey },
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// ignore
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
finish('Task cancelled');
|
|
401
|
+
};
|
|
402
|
+
signal.addEventListener('abort', abortHandler);
|
|
403
|
+
// Handle timeout
|
|
404
|
+
let taskTimeout;
|
|
211
405
|
if (task.timeout) {
|
|
212
|
-
setTimeout(() => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
406
|
+
taskTimeout = setTimeout(() => {
|
|
407
|
+
finish('Task timed out');
|
|
408
|
+
}, task.timeout);
|
|
409
|
+
}
|
|
410
|
+
// Handle incoming events
|
|
411
|
+
ws.on('message', (data) => {
|
|
412
|
+
if (finished)
|
|
413
|
+
return;
|
|
414
|
+
let frame;
|
|
415
|
+
try {
|
|
416
|
+
frame = JSON.parse(String(data));
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// Handle chat.send response
|
|
422
|
+
if (frame.type === 'res' && frame.id === 'chat-send-1') {
|
|
423
|
+
if (frame.ok) {
|
|
424
|
+
runId = frame.payload?.runId;
|
|
425
|
+
stream.status('running', 10, 'Task dispatched to agent');
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
finish(`Gateway rejected task: ${frame.error?.message || 'unknown'}`);
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
// Handle agent events
|
|
433
|
+
if (frame.type === 'event' && frame.event === 'agent') {
|
|
434
|
+
const p = frame.payload || {};
|
|
435
|
+
// Filter to our session — gateway prepends 'agent:main:' to sessionKey
|
|
436
|
+
if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const streamType = p.stream;
|
|
440
|
+
const eventData = p.data;
|
|
441
|
+
if (streamType === 'lifecycle') {
|
|
442
|
+
const phase = eventData?.phase;
|
|
443
|
+
if (phase === 'start') {
|
|
444
|
+
stream.sessionInit(p.sessionKey || sessionKey, eventData?.model || undefined);
|
|
445
|
+
}
|
|
446
|
+
else if (phase === 'end') {
|
|
447
|
+
// Extract usage metrics from lifecycle.end if available
|
|
448
|
+
const usage = eventData?.usage;
|
|
449
|
+
const cost = (eventData?.total_cost_usd ?? eventData?.cost_usd);
|
|
450
|
+
const numTurns = eventData?.num_turns;
|
|
451
|
+
const durationMs = eventData?.duration_ms;
|
|
452
|
+
const model = eventData?.model;
|
|
453
|
+
if (usage || cost !== undefined) {
|
|
454
|
+
lastMetrics = {
|
|
455
|
+
inputTokens: usage?.input_tokens,
|
|
456
|
+
outputTokens: usage?.output_tokens,
|
|
457
|
+
totalCost: cost,
|
|
458
|
+
numTurns,
|
|
459
|
+
durationMs,
|
|
460
|
+
model,
|
|
461
|
+
};
|
|
218
462
|
}
|
|
219
|
-
|
|
463
|
+
lifecycleEnded = true;
|
|
464
|
+
tryFinishAfterLifecycle();
|
|
465
|
+
}
|
|
220
466
|
}
|
|
221
|
-
|
|
467
|
+
else if (streamType === 'assistant') {
|
|
468
|
+
const delta = eventData?.delta || eventData?.text;
|
|
469
|
+
if (delta) {
|
|
470
|
+
outputText += delta;
|
|
471
|
+
stream.text(delta);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
else if (streamType === 'tool_use') {
|
|
475
|
+
const toolName = eventData?.name || eventData?.toolName || 'unknown';
|
|
476
|
+
const toolInput = eventData?.input || eventData?.toolInput || {};
|
|
477
|
+
stream.toolUse(toolName, toolInput);
|
|
478
|
+
}
|
|
479
|
+
else if (streamType === 'tool_result') {
|
|
480
|
+
const toolName = eventData?.name || eventData?.toolName || 'unknown';
|
|
481
|
+
const result = eventData?.result || eventData?.output || '';
|
|
482
|
+
const success = eventData?.success !== false;
|
|
483
|
+
stream.toolResult(toolName, result, success);
|
|
484
|
+
}
|
|
485
|
+
else if (streamType === 'file_change') {
|
|
486
|
+
const filePath = eventData?.path || eventData?.file;
|
|
487
|
+
const rawAction = eventData?.type || eventData?.action || 'modified';
|
|
488
|
+
const action = (['created', 'modified', 'deleted'].includes(rawAction) ? rawAction : 'modified');
|
|
489
|
+
if (filePath) {
|
|
490
|
+
artifacts.push({ type: 'file', name: filePath, path: filePath, metadata: { action } });
|
|
491
|
+
stream.fileChange(filePath, action);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Handle chat events (for final state + model info)
|
|
497
|
+
if (frame.type === 'event' && frame.event === 'chat') {
|
|
498
|
+
const p = frame.payload || {};
|
|
499
|
+
// Filter to our session — gateway prepends 'agent:main:' to sessionKey
|
|
500
|
+
if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const state = p.state;
|
|
504
|
+
if (state === 'final') {
|
|
505
|
+
chatFinalReceived = true;
|
|
506
|
+
// Extract final message content
|
|
507
|
+
const message = p.message;
|
|
508
|
+
if (message) {
|
|
509
|
+
const content = message.content;
|
|
510
|
+
if (content) {
|
|
511
|
+
for (const block of content) {
|
|
512
|
+
if (block.type === 'text' && block.text) {
|
|
513
|
+
// Only add if not already captured via agent delta events
|
|
514
|
+
if (!outputText.includes(block.text)) {
|
|
515
|
+
outputText += block.text;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Extract model/usage from chat.final if not yet captured
|
|
522
|
+
if (!lastMetrics) {
|
|
523
|
+
const usage = p.usage;
|
|
524
|
+
const cost = (p.total_cost_usd ?? p.cost_usd);
|
|
525
|
+
const model = (p.model ?? message?.model);
|
|
526
|
+
if (usage || cost !== undefined || model) {
|
|
527
|
+
lastMetrics = {
|
|
528
|
+
inputTokens: usage?.input_tokens,
|
|
529
|
+
outputTokens: usage?.output_tokens,
|
|
530
|
+
totalCost: cost,
|
|
531
|
+
model,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// If lifecycle already ended, finish immediately
|
|
536
|
+
if (lifecycleEnded)
|
|
537
|
+
finish();
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// Handle tick/health/presence (ignore)
|
|
542
|
+
if (frame.type === 'event') {
|
|
543
|
+
const ignoredEvents = ['tick', 'health', 'presence', 'heartbeat'];
|
|
544
|
+
if (frame.event && ignoredEvents.includes(frame.event))
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
ws.on('close', () => {
|
|
549
|
+
if (!finished) {
|
|
550
|
+
finish('Gateway connection closed unexpectedly');
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
ws.on('error', (err) => {
|
|
554
|
+
if (!finished) {
|
|
555
|
+
finish(`Gateway WebSocket error: ${err.message}`);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
// Build the prompt
|
|
559
|
+
const effectivePrompt = task.systemPrompt
|
|
560
|
+
? `${task.systemPrompt}\n\n---\n\n${task.prompt}`
|
|
561
|
+
: task.prompt;
|
|
562
|
+
// Send chat.send
|
|
563
|
+
try {
|
|
564
|
+
ws.send(JSON.stringify({
|
|
565
|
+
type: 'req',
|
|
566
|
+
id: 'chat-send-1',
|
|
567
|
+
method: 'chat.send',
|
|
568
|
+
params: {
|
|
569
|
+
sessionKey,
|
|
570
|
+
message: effectivePrompt,
|
|
571
|
+
idempotencyKey,
|
|
572
|
+
},
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
finish(`Failed to send chat.send: ${err instanceof Error ? err.message : String(err)}`);
|
|
222
577
|
}
|
|
578
|
+
// Note: signal listener and timeout cleanup is handled in finish()
|
|
223
579
|
});
|
|
224
580
|
}
|
|
581
|
+
// ─── Reusable Chat Message Sender (for resume) ────────────────────
|
|
225
582
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* OpenClaw JSONL event types:
|
|
229
|
-
* - session.start → sessionInit
|
|
230
|
-
* - content.text → text output
|
|
231
|
-
* - tool_use.start → toolUse
|
|
232
|
-
* - tool_use.end → toolResult
|
|
233
|
-
* - file.change → fileChange
|
|
234
|
-
* - message.start → status update (agent thinking)
|
|
235
|
-
* - message.end → status update (turn complete)
|
|
236
|
-
* - session.end → metrics extraction
|
|
237
|
-
*
|
|
238
|
-
* Returns metrics if a session.end event is processed.
|
|
583
|
+
* Send a chat message to an already-connected gateway WebSocket.
|
|
584
|
+
* Used by resumeTask() to continue a conversation on the same sessionKey.
|
|
239
585
|
*/
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
586
|
+
sendChatMessage(ws, sessionKey, message, stream, signal) {
|
|
587
|
+
return new Promise((resolve) => {
|
|
588
|
+
const idempotencyKey = randomUUID();
|
|
589
|
+
let outputText = '';
|
|
590
|
+
let finished = false;
|
|
591
|
+
let lifecycleEnded = false;
|
|
592
|
+
let chatFinalReceived = false;
|
|
593
|
+
let gracePeriodTimeout;
|
|
594
|
+
const finish = (error) => {
|
|
595
|
+
if (finished)
|
|
596
|
+
return;
|
|
597
|
+
finished = true;
|
|
598
|
+
signal.removeEventListener('abort', abortHandler);
|
|
599
|
+
if (gracePeriodTimeout)
|
|
600
|
+
clearTimeout(gracePeriodTimeout);
|
|
601
|
+
ws.removeAllListeners();
|
|
602
|
+
ws.close();
|
|
603
|
+
resolve({ output: outputText, error });
|
|
604
|
+
};
|
|
605
|
+
const tryFinishAfterLifecycle = () => {
|
|
606
|
+
if (chatFinalReceived) {
|
|
607
|
+
finish();
|
|
252
608
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
609
|
+
else {
|
|
610
|
+
gracePeriodTimeout = setTimeout(() => { if (!finished)
|
|
611
|
+
finish(); }, 500);
|
|
256
612
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
613
|
+
};
|
|
614
|
+
const abortHandler = () => {
|
|
615
|
+
try {
|
|
616
|
+
ws.send(JSON.stringify({
|
|
617
|
+
type: 'req',
|
|
618
|
+
id: 'abort-resume',
|
|
619
|
+
method: 'chat.abort',
|
|
620
|
+
params: { sessionKey },
|
|
621
|
+
}));
|
|
263
622
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
623
|
+
catch { /* ignore */ }
|
|
624
|
+
finish('Task cancelled');
|
|
625
|
+
};
|
|
626
|
+
signal.addEventListener('abort', abortHandler);
|
|
627
|
+
ws.on('message', (data) => {
|
|
628
|
+
if (finished)
|
|
629
|
+
return;
|
|
630
|
+
let frame;
|
|
631
|
+
try {
|
|
632
|
+
frame = JSON.parse(String(data));
|
|
269
633
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const result = event.result;
|
|
273
|
-
const success = event.success !== false;
|
|
274
|
-
stream.toolResult(toolName, result, success);
|
|
275
|
-
break;
|
|
634
|
+
catch {
|
|
635
|
+
return;
|
|
276
636
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const linesAdded = event.lines_added;
|
|
281
|
-
const linesRemoved = event.lines_removed;
|
|
282
|
-
if (path) {
|
|
283
|
-
stream.fileChange(path, action, linesAdded, linesRemoved);
|
|
284
|
-
if (!artifacts.some((a) => a.path === path)) {
|
|
285
|
-
artifacts.push({ type: 'file', name: path, path });
|
|
286
|
-
}
|
|
637
|
+
if (frame.type === 'res' && frame.id === 'chat-resume-1') {
|
|
638
|
+
if (!frame.ok) {
|
|
639
|
+
finish(`Gateway rejected resume: ${frame.error?.message || 'unknown'}`);
|
|
287
640
|
}
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
case 'message.end': {
|
|
291
|
-
stream.status('running', undefined, 'Turn complete');
|
|
292
|
-
break;
|
|
641
|
+
return;
|
|
293
642
|
}
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
643
|
+
if (frame.type === 'event' && frame.event === 'agent') {
|
|
644
|
+
const p = frame.payload || {};
|
|
645
|
+
if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey)
|
|
646
|
+
return;
|
|
647
|
+
const streamType = p.stream;
|
|
648
|
+
const eventData = p.data;
|
|
649
|
+
if (streamType === 'lifecycle') {
|
|
650
|
+
const phase = eventData?.phase;
|
|
651
|
+
if (phase === 'end') {
|
|
652
|
+
lifecycleEnded = true;
|
|
653
|
+
tryFinishAfterLifecycle();
|
|
654
|
+
}
|
|
303
655
|
}
|
|
304
|
-
else {
|
|
305
|
-
|
|
656
|
+
else if (streamType === 'assistant') {
|
|
657
|
+
const delta = eventData?.delta || eventData?.text;
|
|
658
|
+
if (delta) {
|
|
659
|
+
outputText += delta;
|
|
660
|
+
stream.text(delta);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
else if (streamType === 'tool_use') {
|
|
664
|
+
const toolName = eventData?.name || 'unknown';
|
|
665
|
+
stream.toolUse(toolName, eventData?.input || {});
|
|
666
|
+
}
|
|
667
|
+
else if (streamType === 'tool_result') {
|
|
668
|
+
const toolName = eventData?.name || 'unknown';
|
|
669
|
+
stream.toolResult(toolName, eventData?.result || '', eventData?.success !== false);
|
|
670
|
+
}
|
|
671
|
+
else if (streamType === 'file_change') {
|
|
672
|
+
const filePath = eventData?.path || eventData?.file;
|
|
673
|
+
if (filePath) {
|
|
674
|
+
const rawAction = eventData?.type || 'modified';
|
|
675
|
+
const action = (['created', 'modified', 'deleted'].includes(rawAction) ? rawAction : 'modified');
|
|
676
|
+
stream.fileChange(filePath, action);
|
|
677
|
+
}
|
|
306
678
|
}
|
|
307
|
-
return
|
|
308
|
-
totalCost: cost,
|
|
309
|
-
inputTokens,
|
|
310
|
-
outputTokens,
|
|
311
|
-
numTurns: turns,
|
|
312
|
-
model,
|
|
313
|
-
durationMs,
|
|
314
|
-
};
|
|
679
|
+
return;
|
|
315
680
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
681
|
+
if (frame.type === 'event' && frame.event === 'chat') {
|
|
682
|
+
const p = frame.payload || {};
|
|
683
|
+
if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey)
|
|
684
|
+
return;
|
|
685
|
+
if (p.state === 'final') {
|
|
686
|
+
chatFinalReceived = true;
|
|
687
|
+
if (lifecycleEnded)
|
|
688
|
+
finish();
|
|
689
|
+
}
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
ws.on('close', () => { if (!finished)
|
|
694
|
+
finish('Gateway connection closed'); });
|
|
695
|
+
ws.on('error', (err) => { if (!finished)
|
|
696
|
+
finish(`Gateway error: ${err.message}`); });
|
|
697
|
+
// Send the resume message
|
|
698
|
+
try {
|
|
699
|
+
ws.send(JSON.stringify({
|
|
700
|
+
type: 'req',
|
|
701
|
+
id: 'chat-resume-1',
|
|
702
|
+
method: 'chat.send',
|
|
703
|
+
params: {
|
|
704
|
+
sessionKey,
|
|
705
|
+
message,
|
|
706
|
+
idempotencyKey,
|
|
707
|
+
},
|
|
708
|
+
}));
|
|
319
709
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
return undefined;
|
|
710
|
+
catch (err) {
|
|
711
|
+
finish(`Failed to send chat.send: ${err instanceof Error ? err.message : String(err)}`);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
326
714
|
}
|
|
327
715
|
}
|
|
328
716
|
//# sourceMappingURL=openclaw-adapter.js.map
|