ocuclaw 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/README.md +25 -0
- package/dist/config/runtime-config.js +165 -0
- package/dist/domain/activity-status-adapter.js +1041 -0
- package/dist/domain/conversation-state.js +516 -0
- package/dist/domain/debug-store.js +700 -0
- package/dist/domain/message-emoji-filter.js +249 -0
- package/dist/domain/readability-system-prompt.js +17 -0
- package/dist/even-ai/even-ai-endpoint.js +938 -0
- package/dist/even-ai/even-ai-model-hook.js +80 -0
- package/dist/even-ai/even-ai-router.js +98 -0
- package/dist/even-ai/even-ai-run-waiter.js +265 -0
- package/dist/even-ai/even-ai-settings-store.js +365 -0
- package/dist/gateway/gateway-bridge.js +175 -0
- package/dist/gateway/openclaw-client.js +1570 -0
- package/dist/index.js +38 -0
- package/dist/runtime/downstream-handler.js +2747 -0
- package/dist/runtime/downstream-server.js +1565 -0
- package/dist/runtime/ocuclaw-settings-store.js +237 -0
- package/dist/runtime/protocol-adapter.js +378 -0
- package/dist/runtime/relay-core.js +1977 -0
- package/dist/runtime/relay-service.js +146 -0
- package/dist/runtime/session-service.js +1026 -0
- package/dist/runtime/upstream-runtime.js +931 -0
- package/openclaw.plugin.json +95 -0
- package/package.json +36 -0
|
@@ -0,0 +1,1570 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import WebSocket from "ws";
|
|
6
|
+
|
|
7
|
+
// --- Constants ---
|
|
8
|
+
|
|
9
|
+
const DEVICE_KEY_FILE = "ocuclaw-device-key.json";
|
|
10
|
+
const DEVICE_TOKEN_FILE = "ocuclaw-device-token.json";
|
|
11
|
+
|
|
12
|
+
const CLIENT_ID = "gateway-client";
|
|
13
|
+
const CLIENT_VERSION = "0.1.0";
|
|
14
|
+
const CLIENT_MODE = "backend";
|
|
15
|
+
const ROLE = "operator";
|
|
16
|
+
const SCOPES = [
|
|
17
|
+
"operator.read",
|
|
18
|
+
"operator.write",
|
|
19
|
+
"operator.approvals",
|
|
20
|
+
"operator.admin",
|
|
21
|
+
];
|
|
22
|
+
const PROTOCOL_VERSION = 3;
|
|
23
|
+
const HISTORY_ACTIVITY_POLL_INTERVAL_MS = 500;
|
|
24
|
+
const HISTORY_ACTIVITY_POLL_LIMIT = 40;
|
|
25
|
+
|
|
26
|
+
const THINKING_SUMMARY_KEYS = [
|
|
27
|
+
"summary",
|
|
28
|
+
"thinkingSummary",
|
|
29
|
+
"reasoningSummary",
|
|
30
|
+
"intentLabel",
|
|
31
|
+
];
|
|
32
|
+
const THINKING_DETAIL_KEYS = [
|
|
33
|
+
"thinking",
|
|
34
|
+
"reasoning",
|
|
35
|
+
"thinkingText",
|
|
36
|
+
"analysis",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
40
|
+
|
|
41
|
+
function normalizeLogger(logger) {
|
|
42
|
+
if (!logger || typeof logger !== "object") {
|
|
43
|
+
return {
|
|
44
|
+
info: console.log.bind(console),
|
|
45
|
+
warn: console.warn.bind(console),
|
|
46
|
+
error: console.error.bind(console),
|
|
47
|
+
debug: console.debug.bind(console),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
info:
|
|
52
|
+
typeof logger.info === "function"
|
|
53
|
+
? logger.info.bind(logger)
|
|
54
|
+
: typeof logger.log === "function"
|
|
55
|
+
? logger.log.bind(logger)
|
|
56
|
+
: console.log.bind(console),
|
|
57
|
+
warn:
|
|
58
|
+
typeof logger.warn === "function"
|
|
59
|
+
? logger.warn.bind(logger)
|
|
60
|
+
: console.warn.bind(console),
|
|
61
|
+
error:
|
|
62
|
+
typeof logger.error === "function"
|
|
63
|
+
? logger.error.bind(logger)
|
|
64
|
+
: console.error.bind(console),
|
|
65
|
+
debug:
|
|
66
|
+
typeof logger.debug === "function"
|
|
67
|
+
? logger.debug.bind(logger)
|
|
68
|
+
: typeof logger.info === "function"
|
|
69
|
+
? logger.info.bind(logger)
|
|
70
|
+
: console.debug.bind(console),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickTrimmedString(...values) {
|
|
75
|
+
for (const value of values) {
|
|
76
|
+
if (typeof value !== "string") continue;
|
|
77
|
+
const trimmed = value.trim();
|
|
78
|
+
if (trimmed) return trimmed;
|
|
79
|
+
}
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeStateDir(stateDir) {
|
|
84
|
+
if (typeof stateDir !== "string") return null;
|
|
85
|
+
const trimmed = stateDir.trim();
|
|
86
|
+
return trimmed ? trimmed : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolvePersistencePaths(stateDir) {
|
|
90
|
+
const resolvedStateDir = normalizeStateDir(stateDir);
|
|
91
|
+
if (!resolvedStateDir) return null;
|
|
92
|
+
return {
|
|
93
|
+
stateDir: resolvedStateDir,
|
|
94
|
+
deviceKeyPath: path.join(resolvedStateDir, DEVICE_KEY_FILE),
|
|
95
|
+
deviceTokenPath: path.join(resolvedStateDir, DEVICE_TOKEN_FILE),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeJsonFile(filePath, data) {
|
|
100
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
101
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", {
|
|
102
|
+
mode: 0o600,
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
fs.chmodSync(filePath, 0o600);
|
|
106
|
+
} catch {
|
|
107
|
+
// best-effort
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Base64url helpers ---
|
|
112
|
+
|
|
113
|
+
function base64UrlEncode(buf) {
|
|
114
|
+
return buf
|
|
115
|
+
.toString("base64")
|
|
116
|
+
.replaceAll("+", "-")
|
|
117
|
+
.replaceAll("/", "_")
|
|
118
|
+
.replace(/=+$/g, "");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Device identity ---
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract raw 32-byte Ed25519 public key from SPKI DER.
|
|
125
|
+
* Strips the standard 12-byte SPKI prefix for Ed25519 keys.
|
|
126
|
+
*/
|
|
127
|
+
function derivePublicKeyRaw(publicKeyPem) {
|
|
128
|
+
const key = crypto.createPublicKey(publicKeyPem);
|
|
129
|
+
const spki = key.export({ type: "spki", format: "der" });
|
|
130
|
+
if (
|
|
131
|
+
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
132
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
133
|
+
) {
|
|
134
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
135
|
+
}
|
|
136
|
+
return spki;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* SHA-256 hex hash of the raw 32-byte public key.
|
|
141
|
+
*/
|
|
142
|
+
function fingerprintPublicKey(publicKeyPem) {
|
|
143
|
+
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
144
|
+
return crypto.createHash("sha256").update(raw).digest("hex");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Generate a new Ed25519 keypair.
|
|
149
|
+
*/
|
|
150
|
+
function generateIdentity() {
|
|
151
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
152
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
153
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
154
|
+
const deviceId = fingerprintPublicKey(publicKeyPem);
|
|
155
|
+
return { deviceId, publicKeyPem, privateKeyPem };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Load device identity from disk, or generate and persist a new one.
|
|
160
|
+
*/
|
|
161
|
+
function loadOrCreateDeviceIdentity(persistencePaths, logger) {
|
|
162
|
+
const deviceKeyPath = persistencePaths && persistencePaths.deviceKeyPath;
|
|
163
|
+
// Try loading existing key
|
|
164
|
+
try {
|
|
165
|
+
if (deviceKeyPath && fs.existsSync(deviceKeyPath)) {
|
|
166
|
+
const raw = fs.readFileSync(deviceKeyPath, "utf8");
|
|
167
|
+
const parsed = JSON.parse(raw);
|
|
168
|
+
if (
|
|
169
|
+
parsed &&
|
|
170
|
+
parsed.version === 1 &&
|
|
171
|
+
typeof parsed.deviceId === "string" &&
|
|
172
|
+
typeof parsed.publicKeyPem === "string" &&
|
|
173
|
+
typeof parsed.privateKeyPem === "string"
|
|
174
|
+
) {
|
|
175
|
+
// Verify deviceId matches public key
|
|
176
|
+
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
|
177
|
+
if (derivedId && derivedId !== parsed.deviceId) {
|
|
178
|
+
// Fix stored deviceId
|
|
179
|
+
const updated = { ...parsed, deviceId: derivedId };
|
|
180
|
+
writeJsonFile(deviceKeyPath, updated);
|
|
181
|
+
logger.info(
|
|
182
|
+
`[openclaw] Loaded device identity (fixed ID): ${derivedId.slice(0, 12)}...`
|
|
183
|
+
);
|
|
184
|
+
return {
|
|
185
|
+
deviceId: derivedId,
|
|
186
|
+
publicKeyPem: parsed.publicKeyPem,
|
|
187
|
+
privateKeyPem: parsed.privateKeyPem,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
logger.info(
|
|
191
|
+
`[openclaw] Loaded device identity: ${parsed.deviceId.slice(0, 12)}...`
|
|
192
|
+
);
|
|
193
|
+
return {
|
|
194
|
+
deviceId: parsed.deviceId,
|
|
195
|
+
publicKeyPem: parsed.publicKeyPem,
|
|
196
|
+
privateKeyPem: parsed.privateKeyPem,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Fall through to regenerate
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Generate new identity
|
|
205
|
+
const identity = generateIdentity();
|
|
206
|
+
if (!deviceKeyPath) {
|
|
207
|
+
logger.info(
|
|
208
|
+
`[openclaw] Generated in-memory device identity: ${identity.deviceId.slice(0, 12)}...`
|
|
209
|
+
);
|
|
210
|
+
return identity;
|
|
211
|
+
}
|
|
212
|
+
const stored = {
|
|
213
|
+
version: 1,
|
|
214
|
+
deviceId: identity.deviceId,
|
|
215
|
+
publicKeyPem: identity.publicKeyPem,
|
|
216
|
+
privateKeyPem: identity.privateKeyPem,
|
|
217
|
+
createdAtMs: Date.now(),
|
|
218
|
+
};
|
|
219
|
+
writeJsonFile(deviceKeyPath, stored);
|
|
220
|
+
logger.info(
|
|
221
|
+
`[openclaw] Generated new device identity: ${identity.deviceId.slice(0, 12)}...`
|
|
222
|
+
);
|
|
223
|
+
return identity;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- Device token cache ---
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Load cached device token from disk.
|
|
230
|
+
* Returns token string or null.
|
|
231
|
+
*/
|
|
232
|
+
function loadDeviceToken(deviceId, persistencePaths) {
|
|
233
|
+
const deviceTokenPath = persistencePaths && persistencePaths.deviceTokenPath;
|
|
234
|
+
try {
|
|
235
|
+
if (!deviceTokenPath || !fs.existsSync(deviceTokenPath)) return null;
|
|
236
|
+
const raw = fs.readFileSync(deviceTokenPath, "utf8");
|
|
237
|
+
const parsed = JSON.parse(raw);
|
|
238
|
+
if (
|
|
239
|
+
parsed &&
|
|
240
|
+
parsed.version === 1 &&
|
|
241
|
+
parsed.deviceId === deviceId &&
|
|
242
|
+
typeof parsed.token === "string"
|
|
243
|
+
) {
|
|
244
|
+
return parsed.token;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Store device token to disk.
|
|
254
|
+
*/
|
|
255
|
+
function storeDeviceToken(deviceId, token, role, scopes, persistencePaths) {
|
|
256
|
+
const deviceTokenPath = persistencePaths && persistencePaths.deviceTokenPath;
|
|
257
|
+
if (!deviceTokenPath) return;
|
|
258
|
+
const data = {
|
|
259
|
+
version: 1,
|
|
260
|
+
deviceId,
|
|
261
|
+
token,
|
|
262
|
+
role,
|
|
263
|
+
scopes: scopes || [],
|
|
264
|
+
updatedAtMs: Date.now(),
|
|
265
|
+
};
|
|
266
|
+
writeJsonFile(deviceTokenPath, data);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Clear cached device token.
|
|
271
|
+
*/
|
|
272
|
+
function clearDeviceToken(persistencePaths) {
|
|
273
|
+
const deviceTokenPath = persistencePaths && persistencePaths.deviceTokenPath;
|
|
274
|
+
try {
|
|
275
|
+
if (deviceTokenPath && fs.existsSync(deviceTokenPath)) {
|
|
276
|
+
fs.unlinkSync(deviceTokenPath);
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// best-effort
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- Auth payload ---
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Build the pipe-delimited device auth payload string.
|
|
287
|
+
* Format: v2|{deviceId}|{clientId}|{clientMode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce}
|
|
288
|
+
*/
|
|
289
|
+
function buildDeviceAuthPayload(params) {
|
|
290
|
+
const version = params.nonce ? "v2" : "v1";
|
|
291
|
+
const scopes = params.scopes.join(",");
|
|
292
|
+
const token = params.token || "";
|
|
293
|
+
const parts = [
|
|
294
|
+
version,
|
|
295
|
+
params.deviceId,
|
|
296
|
+
params.clientId,
|
|
297
|
+
params.clientMode,
|
|
298
|
+
params.role,
|
|
299
|
+
scopes,
|
|
300
|
+
String(params.signedAtMs),
|
|
301
|
+
token,
|
|
302
|
+
];
|
|
303
|
+
if (version === "v2") {
|
|
304
|
+
parts.push(params.nonce || "");
|
|
305
|
+
}
|
|
306
|
+
return parts.join("|");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Sign a payload string with Ed25519 private key, return base64url signature.
|
|
311
|
+
*/
|
|
312
|
+
function signPayload(privateKeyPem, payload) {
|
|
313
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
314
|
+
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
|
|
315
|
+
return base64UrlEncode(sig);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get the raw public key as base64url from PEM.
|
|
320
|
+
*/
|
|
321
|
+
function publicKeyRawBase64Url(publicKeyPem) {
|
|
322
|
+
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isObject(value) {
|
|
326
|
+
return value && typeof value === "object" && !Array.isArray(value);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isNullishToken(value) {
|
|
330
|
+
if (typeof value !== "string") return false;
|
|
331
|
+
const normalized = value.trim().toLowerCase();
|
|
332
|
+
return (
|
|
333
|
+
normalized === "null" ||
|
|
334
|
+
normalized === "undefined" ||
|
|
335
|
+
normalized === "(null)" ||
|
|
336
|
+
normalized === "(undefined)" ||
|
|
337
|
+
normalized === "none"
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function pickStringPathFromArgs(args) {
|
|
342
|
+
if (!isObject(args)) return null;
|
|
343
|
+
const keys = [
|
|
344
|
+
"path",
|
|
345
|
+
"filePath",
|
|
346
|
+
"file_path",
|
|
347
|
+
"filepath",
|
|
348
|
+
"file",
|
|
349
|
+
"target",
|
|
350
|
+
"outputPath",
|
|
351
|
+
"output_path",
|
|
352
|
+
"output",
|
|
353
|
+
"destination",
|
|
354
|
+
"dest",
|
|
355
|
+
];
|
|
356
|
+
for (const key of keys) {
|
|
357
|
+
const value = args[key];
|
|
358
|
+
if (typeof value === "string" && value.trim()) {
|
|
359
|
+
const trimmed = value.trim();
|
|
360
|
+
if (!isNullishToken(trimmed)) {
|
|
361
|
+
return trimmed;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function pickFirstString(obj, keys) {
|
|
369
|
+
const entry = pickFirstStringEntry(obj, keys);
|
|
370
|
+
return entry ? entry.value : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function pickFirstStringEntry(obj, keys) {
|
|
374
|
+
if (!isObject(obj)) return null;
|
|
375
|
+
for (const key of keys) {
|
|
376
|
+
const value = obj[key];
|
|
377
|
+
if (typeof value === "string" && value.trim()) {
|
|
378
|
+
return { key, value: value.trim() };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function normalizeThinkingText(raw) {
|
|
385
|
+
if (typeof raw !== "string") return null;
|
|
386
|
+
// Match ActivityStatus iOS app behavior: strip bold markers and trim boundaries only.
|
|
387
|
+
const cleaned = raw
|
|
388
|
+
.replace(/\*\*/g, "")
|
|
389
|
+
.trim();
|
|
390
|
+
return cleaned || null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function extractFirstBoldThinkingSegment(raw) {
|
|
394
|
+
if (typeof raw !== "string") return null;
|
|
395
|
+
const match = raw.match(/\*\*([\s\S]+?)\*\*/);
|
|
396
|
+
if (!match) return null;
|
|
397
|
+
return normalizeThinkingText(match[1]);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function normalizeThinkingSummarySource(rawSource) {
|
|
401
|
+
if (typeof rawSource !== "string") return null;
|
|
402
|
+
const normalized = rawSource.trim().toLowerCase();
|
|
403
|
+
if (
|
|
404
|
+
normalized === "summary" ||
|
|
405
|
+
normalized === "bold" ||
|
|
406
|
+
normalized === "detail" ||
|
|
407
|
+
normalized === "generic"
|
|
408
|
+
) {
|
|
409
|
+
return normalized;
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function selectThinkingDisplayLabel({
|
|
415
|
+
summaryText,
|
|
416
|
+
boldLabelCandidate,
|
|
417
|
+
detailText,
|
|
418
|
+
preferredSource,
|
|
419
|
+
}) {
|
|
420
|
+
const candidates = [
|
|
421
|
+
{ source: "summary", text: summaryText },
|
|
422
|
+
{ source: "bold", text: boldLabelCandidate },
|
|
423
|
+
{ source: "detail", text: detailText },
|
|
424
|
+
];
|
|
425
|
+
if (preferredSource) {
|
|
426
|
+
const preferred = candidates.find((candidate) => (
|
|
427
|
+
candidate.source === preferredSource &&
|
|
428
|
+
candidate.text
|
|
429
|
+
));
|
|
430
|
+
if (preferred) return preferred;
|
|
431
|
+
}
|
|
432
|
+
return candidates.find((candidate) => candidate.text) || null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function buildThinkingDebugRawPayload(raw) {
|
|
436
|
+
if (!isObject(raw)) return null;
|
|
437
|
+
const snapshot = {};
|
|
438
|
+
const keys = [
|
|
439
|
+
"type",
|
|
440
|
+
...THINKING_SUMMARY_KEYS,
|
|
441
|
+
...THINKING_DETAIL_KEYS,
|
|
442
|
+
"thinkingSignature",
|
|
443
|
+
];
|
|
444
|
+
for (const key of keys) {
|
|
445
|
+
const value = raw[key];
|
|
446
|
+
if (typeof value === "string" && value.trim()) {
|
|
447
|
+
snapshot[key] = value.trim();
|
|
448
|
+
} else if (key === "type" && value != null) {
|
|
449
|
+
snapshot[key] = String(value);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return snapshot;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function parseThinkingSignatureId(rawSignature) {
|
|
456
|
+
if (!rawSignature) return null;
|
|
457
|
+
if (typeof rawSignature === "string") {
|
|
458
|
+
try {
|
|
459
|
+
const parsed = JSON.parse(rawSignature);
|
|
460
|
+
if (parsed && typeof parsed.id === "string" && parsed.id.trim()) {
|
|
461
|
+
return parsed.id.trim();
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
if (
|
|
469
|
+
isObject(rawSignature) &&
|
|
470
|
+
typeof rawSignature.id === "string" &&
|
|
471
|
+
rawSignature.id.trim()
|
|
472
|
+
) {
|
|
473
|
+
return rawSignature.id.trim();
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function extractThinkingPayload(raw) {
|
|
479
|
+
if (!isObject(raw)) return null;
|
|
480
|
+
const summaryEntry = pickFirstStringEntry(raw, THINKING_SUMMARY_KEYS);
|
|
481
|
+
const detailEntry = pickFirstStringEntry(raw, THINKING_DETAIL_KEYS);
|
|
482
|
+
const summaryText = normalizeThinkingText(summaryEntry ? summaryEntry.value : null);
|
|
483
|
+
const detailText = normalizeThinkingText(detailEntry ? detailEntry.value : null);
|
|
484
|
+
const boldLabelCandidate = extractFirstBoldThinkingSegment(detailEntry ? detailEntry.value : null);
|
|
485
|
+
const explicitSource = normalizeThinkingSummarySource(
|
|
486
|
+
pickFirstString(raw, ["thinkingSummarySource", "labelSource"])
|
|
487
|
+
);
|
|
488
|
+
const selectedLabel = selectThinkingDisplayLabel({
|
|
489
|
+
summaryText,
|
|
490
|
+
boldLabelCandidate,
|
|
491
|
+
detailText,
|
|
492
|
+
preferredSource: explicitSource,
|
|
493
|
+
});
|
|
494
|
+
if (!selectedLabel) return null;
|
|
495
|
+
const label = selectedLabel.text;
|
|
496
|
+
const thinkingSummarySource = selectedLabel.source;
|
|
497
|
+
const labelEntry = thinkingSummarySource === "summary" ? summaryEntry : detailEntry;
|
|
498
|
+
return {
|
|
499
|
+
label,
|
|
500
|
+
detail: detailText || label,
|
|
501
|
+
signatureId: parseThinkingSignatureId(raw.thinkingSignature),
|
|
502
|
+
summaryKey: summaryEntry ? summaryEntry.key : null,
|
|
503
|
+
detailKey: detailEntry ? detailEntry.key : null,
|
|
504
|
+
summaryText,
|
|
505
|
+
detailText,
|
|
506
|
+
labelSource: thinkingSummarySource,
|
|
507
|
+
thinkingSummarySource,
|
|
508
|
+
labelKey: labelEntry ? labelEntry.key : null,
|
|
509
|
+
labelRaw: labelEntry ? labelEntry.value : null,
|
|
510
|
+
boldLabelCandidate,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function extractHistoryTimestampMs(rawMessage) {
|
|
515
|
+
if (!isObject(rawMessage)) return null;
|
|
516
|
+
const ts = rawMessage.timestamp;
|
|
517
|
+
if (Number.isFinite(ts)) return Math.floor(ts);
|
|
518
|
+
if (typeof ts === "string" && ts.trim()) {
|
|
519
|
+
const parsed = Number(ts);
|
|
520
|
+
if (Number.isFinite(parsed)) return Math.floor(parsed);
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function normalizeRunId(rawRunId) {
|
|
526
|
+
if (typeof rawRunId !== "string") return null;
|
|
527
|
+
const trimmed = rawRunId.trim();
|
|
528
|
+
return trimmed || null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function normalizeSessionKey(rawSessionKey) {
|
|
532
|
+
if (typeof rawSessionKey !== "string") return null;
|
|
533
|
+
const trimmed = rawSessionKey.trim();
|
|
534
|
+
return trimmed || null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function hashThinkingKey(seed) {
|
|
538
|
+
return crypto.createHash("sha1").update(seed).digest("hex").slice(0, 16);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// --- OpenClaw Gateway Client ---
|
|
542
|
+
|
|
543
|
+
class OpenClawClient extends EventEmitter {
|
|
544
|
+
constructor(opts = {}) {
|
|
545
|
+
super();
|
|
546
|
+
this._logger = normalizeLogger(opts.logger);
|
|
547
|
+
this._gatewayUrl = pickTrimmedString(opts.gatewayUrl);
|
|
548
|
+
this._gatewayToken = pickTrimmedString(opts.gatewayToken);
|
|
549
|
+
this._persistencePaths = resolvePersistencePaths(opts.stateDir);
|
|
550
|
+
this._ws = null;
|
|
551
|
+
this._stopped = false;
|
|
552
|
+
this._pending = new Map(); // id -> { resolve, reject, expectFinal }
|
|
553
|
+
this._identity = null;
|
|
554
|
+
this._connectNonce = null;
|
|
555
|
+
this._connectSent = false;
|
|
556
|
+
this._connectTimer = null;
|
|
557
|
+
this._tickIntervalMs = 30000;
|
|
558
|
+
this._deviceToken = null; // cached from hello-ok
|
|
559
|
+
|
|
560
|
+
// --- Reconnection (step 7) ---
|
|
561
|
+
this._backoffMs = 1000;
|
|
562
|
+
this._reconnectTimer = null;
|
|
563
|
+
|
|
564
|
+
// --- Tick watch (step 7) ---
|
|
565
|
+
this._lastTick = null;
|
|
566
|
+
this._tickWatchTimer = null;
|
|
567
|
+
|
|
568
|
+
// --- Agent run state (steps 4-5) ---
|
|
569
|
+
this._activeRunId = null;
|
|
570
|
+
this._activeRunSessionKey = null;
|
|
571
|
+
this._activeRunStartedAtMs = null;
|
|
572
|
+
this._activeRunGeneration = 0;
|
|
573
|
+
this._runTextBuffer = ""; // buffered assistant deltas for current run
|
|
574
|
+
|
|
575
|
+
// --- Agent identity (step 6) ---
|
|
576
|
+
this._agentIdentity = null;
|
|
577
|
+
|
|
578
|
+
// --- Sequence tracking (step 8) ---
|
|
579
|
+
this._lastSeq = null;
|
|
580
|
+
this._gapDuringRun = false; // set if gap detected while run active
|
|
581
|
+
|
|
582
|
+
// --- History hydration (step 9) ---
|
|
583
|
+
this._historyResolved = false;
|
|
584
|
+
this._eventQueue = []; // queued agent events until history resolves
|
|
585
|
+
this._historyActivityPollTimer = null;
|
|
586
|
+
this._historyActivityPollInFlightGeneration = null;
|
|
587
|
+
this._seenThinkingSummaryIds = new Set();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
setLogger(logger) {
|
|
591
|
+
this._logger = normalizeLogger(logger);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Begin connecting to the gateway (non-blocking).
|
|
596
|
+
*/
|
|
597
|
+
start() {
|
|
598
|
+
if (this._stopped) return;
|
|
599
|
+
|
|
600
|
+
// Load or create device identity (only on first start)
|
|
601
|
+
if (!this._identity) {
|
|
602
|
+
this._identity = loadOrCreateDeviceIdentity(
|
|
603
|
+
this._persistencePaths,
|
|
604
|
+
this._logger,
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
// Load cached device token
|
|
608
|
+
this._deviceToken = loadDeviceToken(
|
|
609
|
+
this._identity.deviceId,
|
|
610
|
+
this._persistencePaths,
|
|
611
|
+
);
|
|
612
|
+
if (this._deviceToken) {
|
|
613
|
+
this._logger.info("[openclaw] Loaded cached device token");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
this._connect();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Disconnect and stop.
|
|
622
|
+
*/
|
|
623
|
+
stop() {
|
|
624
|
+
this._stopped = true;
|
|
625
|
+
if (this._connectTimer) {
|
|
626
|
+
clearTimeout(this._connectTimer);
|
|
627
|
+
this._connectTimer = null;
|
|
628
|
+
}
|
|
629
|
+
if (this._reconnectTimer) {
|
|
630
|
+
clearTimeout(this._reconnectTimer);
|
|
631
|
+
this._reconnectTimer = null;
|
|
632
|
+
}
|
|
633
|
+
this._invalidateActiveRun();
|
|
634
|
+
this._stopTickWatch();
|
|
635
|
+
if (this._ws) {
|
|
636
|
+
this._ws.close();
|
|
637
|
+
this._ws = null;
|
|
638
|
+
}
|
|
639
|
+
this._flushPendingErrors(new Error("client stopped"));
|
|
640
|
+
this.emit("status", "stopped");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Send a request to the gateway. Returns a promise that resolves with the response payload.
|
|
645
|
+
* @param {string} method
|
|
646
|
+
* @param {object} [params]
|
|
647
|
+
* @param {{ expectFinal?: boolean }} [opts] - If expectFinal is true, skip intermediate
|
|
648
|
+
* acks (status: "accepted") and resolve only on the final response.
|
|
649
|
+
*/
|
|
650
|
+
request(method, params, opts) {
|
|
651
|
+
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
|
652
|
+
return Promise.reject(new Error("gateway not connected"));
|
|
653
|
+
}
|
|
654
|
+
const id = crypto.randomUUID();
|
|
655
|
+
const frame = { type: "req", id, method, params };
|
|
656
|
+
const expectFinal = opts && opts.expectFinal === true;
|
|
657
|
+
const promise = new Promise((resolve, reject) => {
|
|
658
|
+
this._pending.set(id, { resolve, reject, expectFinal });
|
|
659
|
+
});
|
|
660
|
+
const raw = JSON.stringify(frame);
|
|
661
|
+
this.emit("protocol", { direction: "out", frame });
|
|
662
|
+
this._ws.send(raw);
|
|
663
|
+
return promise;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// --- Public: messaging (step 4) ---
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Send a user message to the OpenClaw agent.
|
|
670
|
+
* Fire-and-forget: sends the request, streaming events arrive via event handlers.
|
|
671
|
+
* @param {string} text - Message text
|
|
672
|
+
* @param {string} [sessionKey="main"] - Session key
|
|
673
|
+
* @param {object|null} [attachment] - Optional image attachment payload
|
|
674
|
+
*/
|
|
675
|
+
sendMessage(text, sessionKey, attachment) {
|
|
676
|
+
const key = sessionKey || "main";
|
|
677
|
+
const idempotencyKey = crypto.randomUUID();
|
|
678
|
+
const params = { message: text, sessionKey: key, idempotencyKey };
|
|
679
|
+
if (
|
|
680
|
+
attachment &&
|
|
681
|
+
typeof attachment === "object" &&
|
|
682
|
+
typeof attachment.base64Data === "string" &&
|
|
683
|
+
attachment.base64Data
|
|
684
|
+
) {
|
|
685
|
+
params.attachments = [
|
|
686
|
+
{
|
|
687
|
+
type: attachment.kind || "image",
|
|
688
|
+
mimeType: attachment.mimeType || "image/jpeg",
|
|
689
|
+
fileName: attachment.name || "image.jpg",
|
|
690
|
+
content: attachment.base64Data,
|
|
691
|
+
},
|
|
692
|
+
];
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Resolve on the initial ack (accepted/queued) for immediate feedback.
|
|
696
|
+
// Agent streaming events arrive independently via event handlers.
|
|
697
|
+
return this.request(
|
|
698
|
+
"agent",
|
|
699
|
+
params,
|
|
700
|
+
).then((result) => {
|
|
701
|
+
const status = result && result.status;
|
|
702
|
+
if (result && result.runId) {
|
|
703
|
+
this._activeRunId = result.runId;
|
|
704
|
+
this._logger.info(`[openclaw] Agent run accepted: ${result.runId}`);
|
|
705
|
+
}
|
|
706
|
+
return result;
|
|
707
|
+
}).catch((err) => {
|
|
708
|
+
this._logger.error(`[openclaw] Agent request failed: ${err.message}`);
|
|
709
|
+
this.emit("error", err);
|
|
710
|
+
throw err;
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// --- Public: agent identity (step 6) ---
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Fetch agent identity from the gateway. Caches the result.
|
|
718
|
+
* @param {string} [sessionKey] - Optional session key
|
|
719
|
+
* @returns {Promise<{agentId, name, emoji, avatar}>}
|
|
720
|
+
*/
|
|
721
|
+
async fetchAgentIdentity(sessionKey) {
|
|
722
|
+
const params = sessionKey ? { sessionKey } : {};
|
|
723
|
+
const result = await this.request("agent.identity.get", params);
|
|
724
|
+
this._agentIdentity = result;
|
|
725
|
+
this.emit("agentIdentity", result);
|
|
726
|
+
this._logger.info(`[openclaw] Agent identity: ${result && result.name}`);
|
|
727
|
+
return result;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Resolve an exec approval request.
|
|
732
|
+
* @param {string} id - Approval request ID
|
|
733
|
+
* @param {string} decision - "allow-once", "allow-always", or "deny"
|
|
734
|
+
* @returns {Promise}
|
|
735
|
+
*/
|
|
736
|
+
resolveApproval(id, decision) {
|
|
737
|
+
return this.request("exec.approval.resolve", { id, decision });
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
_beginActiveRun(runId, sessionKey) {
|
|
741
|
+
this._activeRunGeneration += 1;
|
|
742
|
+
this._activeRunId = normalizeRunId(runId);
|
|
743
|
+
this._activeRunSessionKey = normalizeSessionKey(sessionKey);
|
|
744
|
+
this._activeRunStartedAtMs = Date.now();
|
|
745
|
+
this._runTextBuffer = "";
|
|
746
|
+
this._gapDuringRun = false;
|
|
747
|
+
this._seenThinkingSummaryIds.clear();
|
|
748
|
+
return this._activeRunGeneration;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
_invalidateActiveRun() {
|
|
752
|
+
this._activeRunGeneration += 1;
|
|
753
|
+
this._stopHistoryActivityPolling();
|
|
754
|
+
this._activeRunId = null;
|
|
755
|
+
this._activeRunSessionKey = null;
|
|
756
|
+
this._activeRunStartedAtMs = null;
|
|
757
|
+
this._runTextBuffer = "";
|
|
758
|
+
this._gapDuringRun = false;
|
|
759
|
+
this._seenThinkingSummaryIds.clear();
|
|
760
|
+
return this._activeRunGeneration;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
_isActiveRunContextCurrent(context) {
|
|
764
|
+
if (!context || typeof context !== "object") return false;
|
|
765
|
+
return (
|
|
766
|
+
this._activeRunGeneration === context.generation &&
|
|
767
|
+
normalizeRunId(this._activeRunId) === context.runId &&
|
|
768
|
+
normalizeSessionKey(this._activeRunSessionKey) === context.sessionKey &&
|
|
769
|
+
Boolean(normalizeRunId(this._activeRunId)) &&
|
|
770
|
+
Boolean(normalizeSessionKey(this._activeRunSessionKey))
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// --- Internal: connection ---
|
|
775
|
+
|
|
776
|
+
_connect() {
|
|
777
|
+
if (this._stopped) return;
|
|
778
|
+
|
|
779
|
+
// Close any existing WebSocket to prevent parallel connections
|
|
780
|
+
// (e.g., from shutdown handler scheduling reconnect before close fires)
|
|
781
|
+
if (this._ws) {
|
|
782
|
+
try { this._ws.close(); } catch { /* ignore */ }
|
|
783
|
+
this._ws = null;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const url = this._gatewayUrl;
|
|
787
|
+
this.emit("status", "connecting");
|
|
788
|
+
this._logger.info(`[openclaw] Connecting to ${url}`);
|
|
789
|
+
|
|
790
|
+
this._connectNonce = null;
|
|
791
|
+
this._connectSent = false;
|
|
792
|
+
|
|
793
|
+
// Reset per-connection state
|
|
794
|
+
this._lastSeq = null;
|
|
795
|
+
this._lastTick = null;
|
|
796
|
+
this._historyResolved = false;
|
|
797
|
+
this._eventQueue = [];
|
|
798
|
+
this._invalidateActiveRun();
|
|
799
|
+
|
|
800
|
+
const ws = new WebSocket(url, { maxPayload: 25 * 1024 * 1024 });
|
|
801
|
+
this._ws = ws;
|
|
802
|
+
|
|
803
|
+
ws.on("open", () => {
|
|
804
|
+
this._logger.info("[openclaw] WebSocket open, waiting for challenge...");
|
|
805
|
+
// Start a timeout: if we don't receive a challenge, send connect anyway
|
|
806
|
+
// (mirrors the reference client's queueConnect fallback)
|
|
807
|
+
this._connectTimer = setTimeout(() => {
|
|
808
|
+
this._sendConnect();
|
|
809
|
+
}, 750);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
ws.on("message", (data) => {
|
|
813
|
+
this._handleMessage(data.toString());
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
ws.on("close", (code, reason) => {
|
|
817
|
+
const reasonText = reason ? reason.toString() : "";
|
|
818
|
+
this._logger.info(`[openclaw] WebSocket closed: ${code} ${reasonText}`);
|
|
819
|
+
this._ws = null;
|
|
820
|
+
this._stopTickWatch();
|
|
821
|
+
this._stopHistoryActivityPolling();
|
|
822
|
+
this._flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
|
|
823
|
+
this.emit("disconnected", { code, reason: reasonText });
|
|
824
|
+
this.emit("status", "disconnected");
|
|
825
|
+
// Only schedule reconnect if one isn't already pending
|
|
826
|
+
// (e.g., shutdown handler may have already scheduled with a specific delay)
|
|
827
|
+
if (!this._reconnectTimer) {
|
|
828
|
+
this._scheduleReconnect();
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
ws.on("error", (err) => {
|
|
833
|
+
this._logger.error(`[openclaw] WebSocket error: ${err.message}`);
|
|
834
|
+
if (!this._connectSent) {
|
|
835
|
+
this.emit("error", err);
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// --- Internal: message handling ---
|
|
841
|
+
|
|
842
|
+
_handleMessage(raw) {
|
|
843
|
+
let parsed;
|
|
844
|
+
try {
|
|
845
|
+
parsed = JSON.parse(raw);
|
|
846
|
+
} catch (err) {
|
|
847
|
+
this._logger.error(`[openclaw] Failed to parse message: ${err.message}`);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Emit protocol event for every incoming frame (step 10)
|
|
852
|
+
this.emit("protocol", { direction: "in", frame: parsed });
|
|
853
|
+
|
|
854
|
+
// Event frames: { type: "event", event: "...", payload: ... }
|
|
855
|
+
if (parsed.type === "event") {
|
|
856
|
+
this._handleEvent(parsed);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Response frames: { type: "res", id: "...", ok: true/false, payload: ... }
|
|
861
|
+
if (parsed.type === "res") {
|
|
862
|
+
const pending = this._pending.get(parsed.id);
|
|
863
|
+
if (!pending) return;
|
|
864
|
+
|
|
865
|
+
// If expectFinal, skip intermediate acks (status: "accepted") (step 4)
|
|
866
|
+
const payload = parsed.payload;
|
|
867
|
+
const status = payload && payload.status;
|
|
868
|
+
if (pending.expectFinal && status === "accepted") {
|
|
869
|
+
// Track the runId from the ack
|
|
870
|
+
if (payload.runId) {
|
|
871
|
+
this._activeRunId = payload.runId;
|
|
872
|
+
this._logger.info(`[openclaw] Agent run accepted: ${payload.runId}`);
|
|
873
|
+
}
|
|
874
|
+
return; // Keep the pending entry, wait for final response
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
this._pending.delete(parsed.id);
|
|
878
|
+
if (parsed.ok) {
|
|
879
|
+
pending.resolve(parsed.payload);
|
|
880
|
+
} else {
|
|
881
|
+
const errMsg =
|
|
882
|
+
parsed.error && parsed.error.message ? parsed.error.message : "unknown error";
|
|
883
|
+
const err = new Error(errMsg);
|
|
884
|
+
if (parsed.error && typeof parsed.error.code === "string") {
|
|
885
|
+
err.code = parsed.error.code;
|
|
886
|
+
}
|
|
887
|
+
if (parsed.error && parsed.error.data !== undefined) {
|
|
888
|
+
err.data = parsed.error.data;
|
|
889
|
+
}
|
|
890
|
+
// Check for retryable hint (step 7)
|
|
891
|
+
if (parsed.error && parsed.error.retryable && parsed.error.retryAfterMs) {
|
|
892
|
+
err.retryAfterMs = parsed.error.retryAfterMs;
|
|
893
|
+
}
|
|
894
|
+
pending.reject(err);
|
|
895
|
+
}
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// --- Internal: event routing ---
|
|
901
|
+
|
|
902
|
+
_handleEvent(evt) {
|
|
903
|
+
// connect.challenge is handled before sequence tracking
|
|
904
|
+
if (evt.event === "connect.challenge") {
|
|
905
|
+
const nonce =
|
|
906
|
+
evt.payload && typeof evt.payload.nonce === "string" ? evt.payload.nonce : null;
|
|
907
|
+
if (nonce) {
|
|
908
|
+
this._logger.info("[openclaw] Received connect.challenge");
|
|
909
|
+
this._connectNonce = nonce;
|
|
910
|
+
this._sendConnect();
|
|
911
|
+
}
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// --- Sequence tracking (step 8) ---
|
|
916
|
+
const seq = typeof evt.seq === "number" ? evt.seq : null;
|
|
917
|
+
if (seq !== null) {
|
|
918
|
+
if (this._lastSeq !== null && seq > this._lastSeq + 1) {
|
|
919
|
+
const gapInfo = { expected: this._lastSeq + 1, received: seq };
|
|
920
|
+
this._logger.warn(
|
|
921
|
+
`[openclaw] Sequence gap: expected ${gapInfo.expected}, received ${gapInfo.received}`
|
|
922
|
+
);
|
|
923
|
+
this.emit("gap", gapInfo);
|
|
924
|
+
// Flag gap during active run for post-run re-fetch (step 8)
|
|
925
|
+
if (this._activeRunId) {
|
|
926
|
+
this._gapDuringRun = true;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
this._lastSeq = seq;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// --- Tick handling (step 7) ---
|
|
933
|
+
if (evt.event === "tick") {
|
|
934
|
+
this._lastTick = Date.now();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// --- Shutdown handling (step 7) ---
|
|
939
|
+
if (evt.event === "shutdown") {
|
|
940
|
+
const payload = evt.payload || {};
|
|
941
|
+
const restartMs = typeof payload.restartExpectedMs === "number" ? payload.restartExpectedMs : 5000;
|
|
942
|
+
this._logger.info(`[openclaw] Gateway shutdown, reconnecting in ${restartMs}ms`);
|
|
943
|
+
this.emit("status", "shutdown");
|
|
944
|
+
// Schedule reconnect after the expected restart delay
|
|
945
|
+
this._scheduleReconnect(restartMs);
|
|
946
|
+
// Close the WS immediately to prevent the close handler from scheduling
|
|
947
|
+
// a second reconnect with normal backoff (double-reconnect race).
|
|
948
|
+
if (this._ws) {
|
|
949
|
+
this._ws.close(1000, "shutdown");
|
|
950
|
+
}
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// --- Exec approval events ---
|
|
955
|
+
if (evt.event === "exec.approval.requested") {
|
|
956
|
+
this.emit("approval", evt.payload);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (evt.event === "exec.approval.resolved") {
|
|
961
|
+
this.emit("approvalResolved", evt.payload);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// --- Agent events (step 5) ---
|
|
966
|
+
if (evt.event === "agent") {
|
|
967
|
+
// If history hasn't resolved yet, queue the event (step 9)
|
|
968
|
+
if (!this._historyResolved) {
|
|
969
|
+
this._eventQueue.push(evt);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
this._handleAgentEvent(evt.payload);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// --- Internal: agent streaming (step 5) ---
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Handle an agent event payload.
|
|
981
|
+
* Buffers assistant text deltas, emits activity/message events.
|
|
982
|
+
*/
|
|
983
|
+
_handleAgentEvent(payload) {
|
|
984
|
+
if (!payload) return;
|
|
985
|
+
|
|
986
|
+
const { runId, stream, data } = payload;
|
|
987
|
+
if (!stream || !data) return;
|
|
988
|
+
|
|
989
|
+
switch (stream) {
|
|
990
|
+
case "lifecycle":
|
|
991
|
+
this._handleLifecycleEvent(runId, data, payload.sessionKey);
|
|
992
|
+
break;
|
|
993
|
+
case "assistant":
|
|
994
|
+
this._handleAssistantEvent(runId, data);
|
|
995
|
+
break;
|
|
996
|
+
case "tool":
|
|
997
|
+
this._handleToolEvent(runId, data);
|
|
998
|
+
break;
|
|
999
|
+
case "error":
|
|
1000
|
+
this._logger.error(`[openclaw] Agent error: ${JSON.stringify(data)}`);
|
|
1001
|
+
this.emit("error", new Error(data.message || "agent error"));
|
|
1002
|
+
break;
|
|
1003
|
+
default:
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
_handleLifecycleEvent(runId, data, sessionKey) {
|
|
1009
|
+
switch (data.phase) {
|
|
1010
|
+
case "start":
|
|
1011
|
+
this._beginActiveRun(runId, sessionKey);
|
|
1012
|
+
this._startHistoryActivityPolling();
|
|
1013
|
+
this.emit("activity", {
|
|
1014
|
+
state: "thinking",
|
|
1015
|
+
sessionKey,
|
|
1016
|
+
runId,
|
|
1017
|
+
origin: "lifecycle",
|
|
1018
|
+
phase: "start",
|
|
1019
|
+
});
|
|
1020
|
+
this._logger.info(`[openclaw] Agent run started: ${runId}`);
|
|
1021
|
+
break;
|
|
1022
|
+
|
|
1023
|
+
case "end": {
|
|
1024
|
+
// Assemble full response from buffered text
|
|
1025
|
+
const fullText = this._runTextBuffer;
|
|
1026
|
+
const completedRunId = normalizeRunId(this._activeRunId) || normalizeRunId(runId);
|
|
1027
|
+
const completedSessionKey =
|
|
1028
|
+
normalizeSessionKey(sessionKey) ||
|
|
1029
|
+
normalizeSessionKey(this._activeRunSessionKey) ||
|
|
1030
|
+
null;
|
|
1031
|
+
const gapDuringRun = this._gapDuringRun;
|
|
1032
|
+
|
|
1033
|
+
// Invalidate run state before emitting terminal idle so late history polls
|
|
1034
|
+
// cannot reopen thinking for a completed run.
|
|
1035
|
+
this._invalidateActiveRun();
|
|
1036
|
+
|
|
1037
|
+
this.emit("message", {
|
|
1038
|
+
runId: completedRunId,
|
|
1039
|
+
role: "assistant",
|
|
1040
|
+
content: [{ type: "text", text: fullText }],
|
|
1041
|
+
sessionKey: completedSessionKey,
|
|
1042
|
+
});
|
|
1043
|
+
this.emit("activity", {
|
|
1044
|
+
state: "idle",
|
|
1045
|
+
sessionKey: completedSessionKey,
|
|
1046
|
+
runId: completedRunId,
|
|
1047
|
+
origin: "lifecycle",
|
|
1048
|
+
phase: "end",
|
|
1049
|
+
});
|
|
1050
|
+
this._logger.info(
|
|
1051
|
+
`[openclaw] Agent run ended: ${completedRunId} (${fullText.length} chars)`
|
|
1052
|
+
);
|
|
1053
|
+
|
|
1054
|
+
// If there was a gap during this run, re-fetch history (step 8)
|
|
1055
|
+
if (gapDuringRun) {
|
|
1056
|
+
this._logger.info("[openclaw] Gap detected during run, re-fetching history");
|
|
1057
|
+
this._fetchHistory(completedSessionKey || "main").catch((err) => {
|
|
1058
|
+
this._logger.error(
|
|
1059
|
+
`[openclaw] Post-gap history fetch failed: ${err.message}`
|
|
1060
|
+
);
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
break;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
case "error":
|
|
1067
|
+
this._logger.error(`[openclaw] Agent lifecycle error: ${JSON.stringify(data)}`);
|
|
1068
|
+
{
|
|
1069
|
+
const completedRunId = normalizeRunId(runId) || normalizeRunId(this._activeRunId);
|
|
1070
|
+
const completedSessionKey =
|
|
1071
|
+
normalizeSessionKey(sessionKey) ||
|
|
1072
|
+
normalizeSessionKey(this._activeRunSessionKey) ||
|
|
1073
|
+
null;
|
|
1074
|
+
this._invalidateActiveRun();
|
|
1075
|
+
this.emit("error", new Error(data.message || "agent lifecycle error"));
|
|
1076
|
+
this.emit("activity", {
|
|
1077
|
+
state: "idle",
|
|
1078
|
+
sessionKey: completedSessionKey,
|
|
1079
|
+
runId: completedRunId || null,
|
|
1080
|
+
origin: "lifecycle",
|
|
1081
|
+
phase: "error",
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
break;
|
|
1085
|
+
|
|
1086
|
+
default:
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
_handleAssistantEvent(runId, data) {
|
|
1092
|
+
this._emitThinkingActivityFromPayload(
|
|
1093
|
+
runId,
|
|
1094
|
+
this._activeRunSessionKey,
|
|
1095
|
+
data,
|
|
1096
|
+
"assistant_event",
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
// Gateway sends accumulated text (full text so far), not deltas
|
|
1100
|
+
if (typeof data.text === "string") {
|
|
1101
|
+
this._runTextBuffer = data.text;
|
|
1102
|
+
this.emit("streaming", {
|
|
1103
|
+
text: data.text,
|
|
1104
|
+
sessionKey: this._activeRunSessionKey,
|
|
1105
|
+
runId: runId || this._activeRunId || null,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
_handleToolEvent(runId, data) {
|
|
1111
|
+
if (data.phase !== "start" || !data.name) return;
|
|
1112
|
+
|
|
1113
|
+
const args = isObject(data.args) ? data.args : null;
|
|
1114
|
+
const pathFromData =
|
|
1115
|
+
typeof data.path === "string" && data.path.trim() && !isNullishToken(data.path)
|
|
1116
|
+
? data.path.trim()
|
|
1117
|
+
: null;
|
|
1118
|
+
const pathFromArgs = pickStringPathFromArgs(args);
|
|
1119
|
+
const path = pathFromData || pathFromArgs || null;
|
|
1120
|
+
|
|
1121
|
+
const activity = {
|
|
1122
|
+
state: "thinking",
|
|
1123
|
+
tool: data.name,
|
|
1124
|
+
sessionKey: this._activeRunSessionKey,
|
|
1125
|
+
runId: runId || this._activeRunId || null,
|
|
1126
|
+
origin: "tool",
|
|
1127
|
+
phase: "start",
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
if (args) activity.args = args;
|
|
1131
|
+
if (path) activity.path = path;
|
|
1132
|
+
if (typeof data.toolCallId === "string" && data.toolCallId.trim()) {
|
|
1133
|
+
activity.activityId = data.toolCallId.trim();
|
|
1134
|
+
}
|
|
1135
|
+
if (Number.isFinite(data.seq)) {
|
|
1136
|
+
activity.seq = Math.floor(data.seq);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
this.emit("activity", activity);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
_startHistoryActivityPolling() {
|
|
1143
|
+
this._stopHistoryActivityPolling();
|
|
1144
|
+
if (!this._activeRunId || !this._activeRunSessionKey) return;
|
|
1145
|
+
|
|
1146
|
+
const poll = () => {
|
|
1147
|
+
this._pollHistoryActivity().catch((err) => {
|
|
1148
|
+
if (!err || !err.message || !/gateway not connected/i.test(err.message)) {
|
|
1149
|
+
this._logger.warn(
|
|
1150
|
+
`[openclaw] Thinking-summary poll failed: ${
|
|
1151
|
+
err && err.message ? err.message : String(err)
|
|
1152
|
+
}`
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
poll();
|
|
1159
|
+
this._historyActivityPollTimer = setInterval(
|
|
1160
|
+
poll,
|
|
1161
|
+
HISTORY_ACTIVITY_POLL_INTERVAL_MS,
|
|
1162
|
+
);
|
|
1163
|
+
if (this._historyActivityPollTimer.unref) {
|
|
1164
|
+
this._historyActivityPollTimer.unref();
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
_stopHistoryActivityPolling() {
|
|
1169
|
+
if (this._historyActivityPollTimer) {
|
|
1170
|
+
clearInterval(this._historyActivityPollTimer);
|
|
1171
|
+
this._historyActivityPollTimer = null;
|
|
1172
|
+
}
|
|
1173
|
+
this._historyActivityPollInFlightGeneration = null;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async _pollHistoryActivity() {
|
|
1177
|
+
if (!this._historyResolved) return;
|
|
1178
|
+
const runContext = {
|
|
1179
|
+
generation: this._activeRunGeneration,
|
|
1180
|
+
runId: normalizeRunId(this._activeRunId),
|
|
1181
|
+
sessionKey: normalizeSessionKey(this._activeRunSessionKey),
|
|
1182
|
+
};
|
|
1183
|
+
if (!runContext.runId || !runContext.sessionKey) return;
|
|
1184
|
+
if (this._historyActivityPollInFlightGeneration === runContext.generation) return;
|
|
1185
|
+
|
|
1186
|
+
this._historyActivityPollInFlightGeneration = runContext.generation;
|
|
1187
|
+
try {
|
|
1188
|
+
const result = await this.request("chat.history", {
|
|
1189
|
+
sessionKey: runContext.sessionKey,
|
|
1190
|
+
limit: HISTORY_ACTIVITY_POLL_LIMIT,
|
|
1191
|
+
});
|
|
1192
|
+
if (!this._isActiveRunContextCurrent(runContext)) {
|
|
1193
|
+
this._logger.debug(
|
|
1194
|
+
`[openclaw] Dropped stale thinking-summary poll for run ${runContext.runId}`
|
|
1195
|
+
);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const responseSessionKey =
|
|
1199
|
+
normalizeSessionKey(result && result.sessionKey) || runContext.sessionKey;
|
|
1200
|
+
if (responseSessionKey !== runContext.sessionKey) {
|
|
1201
|
+
this._logger.debug(
|
|
1202
|
+
`[openclaw] Dropped thinking-summary poll with mismatched session ${String(
|
|
1203
|
+
result && result.sessionKey
|
|
1204
|
+
)}`
|
|
1205
|
+
);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const messages = result && Array.isArray(result.messages) ? result.messages : [];
|
|
1209
|
+
this._emitThinkingFromHistory(
|
|
1210
|
+
messages,
|
|
1211
|
+
responseSessionKey,
|
|
1212
|
+
runContext,
|
|
1213
|
+
);
|
|
1214
|
+
} finally {
|
|
1215
|
+
if (this._historyActivityPollInFlightGeneration === runContext.generation) {
|
|
1216
|
+
this._historyActivityPollInFlightGeneration = null;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
_emitThinkingFromHistory(messages, sessionKey, runContext) {
|
|
1222
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
1223
|
+
if (runContext && !this._isActiveRunContextCurrent(runContext)) return;
|
|
1224
|
+
const activeRunId = normalizeRunId(this._activeRunId);
|
|
1225
|
+
const runStartMs = Number.isFinite(this._activeRunStartedAtMs)
|
|
1226
|
+
? this._activeRunStartedAtMs
|
|
1227
|
+
: null;
|
|
1228
|
+
|
|
1229
|
+
for (const message of messages) {
|
|
1230
|
+
if (!isObject(message)) continue;
|
|
1231
|
+
if (message.role !== "assistant") continue;
|
|
1232
|
+
|
|
1233
|
+
const messageRunId = normalizeRunId(message.runId);
|
|
1234
|
+
if (activeRunId && messageRunId && messageRunId !== activeRunId) continue;
|
|
1235
|
+
|
|
1236
|
+
const messageTs = extractHistoryTimestampMs(message);
|
|
1237
|
+
if (
|
|
1238
|
+
activeRunId &&
|
|
1239
|
+
!messageRunId &&
|
|
1240
|
+
runStartMs !== null &&
|
|
1241
|
+
messageTs !== null &&
|
|
1242
|
+
messageTs < runStartMs - 2000
|
|
1243
|
+
) {
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
1248
|
+
for (const contentItem of content) {
|
|
1249
|
+
if (!isObject(contentItem)) continue;
|
|
1250
|
+
if (contentItem.type !== "thinking") continue;
|
|
1251
|
+
this._emitThinkingActivityFromPayload(
|
|
1252
|
+
messageRunId || activeRunId,
|
|
1253
|
+
sessionKey,
|
|
1254
|
+
contentItem,
|
|
1255
|
+
"history",
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
_emitThinkingActivityFromPayload(runId, sessionKey, rawPayload, source = "unknown") {
|
|
1262
|
+
const extracted = extractThinkingPayload(rawPayload);
|
|
1263
|
+
if (!extracted) return;
|
|
1264
|
+
|
|
1265
|
+
const normalizedRunId = normalizeRunId(runId) || normalizeRunId(this._activeRunId);
|
|
1266
|
+
const dedupeSeed = extracted.signatureId || hashThinkingKey(extracted.label);
|
|
1267
|
+
const dedupeKey = `${normalizedRunId || "run"}:${dedupeSeed}`;
|
|
1268
|
+
if (this._seenThinkingSummaryIds.has(dedupeKey)) return;
|
|
1269
|
+
this._seenThinkingSummaryIds.add(dedupeKey);
|
|
1270
|
+
|
|
1271
|
+
this.emit("thinkingDebug", {
|
|
1272
|
+
sessionKey: sessionKey || this._activeRunSessionKey || null,
|
|
1273
|
+
runId: normalizedRunId || null,
|
|
1274
|
+
source,
|
|
1275
|
+
signatureId: extracted.signatureId || null,
|
|
1276
|
+
rawKeys: isObject(rawPayload) ? Object.keys(rawPayload).sort() : [],
|
|
1277
|
+
rawPayload: buildThinkingDebugRawPayload(rawPayload),
|
|
1278
|
+
summaryKey: extracted.summaryKey,
|
|
1279
|
+
detailKey: extracted.detailKey,
|
|
1280
|
+
labelKey: extracted.labelKey,
|
|
1281
|
+
labelRaw: extracted.labelRaw,
|
|
1282
|
+
labelSource: extracted.labelSource,
|
|
1283
|
+
thinkingSummarySource: extracted.thinkingSummarySource,
|
|
1284
|
+
normalizedSummary: extracted.summaryText || null,
|
|
1285
|
+
normalizedDetail: extracted.detailText || null,
|
|
1286
|
+
label: extracted.label,
|
|
1287
|
+
detail: extracted.detail,
|
|
1288
|
+
boldLabelCandidate: extracted.boldLabelCandidate || null,
|
|
1289
|
+
boldLabelMatchesCurrentLabel: extracted.boldLabelCandidate
|
|
1290
|
+
? extracted.boldLabelCandidate === extracted.label
|
|
1291
|
+
: null,
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
this.emit("activity", {
|
|
1295
|
+
state: "thinking",
|
|
1296
|
+
sessionKey: sessionKey || this._activeRunSessionKey || null,
|
|
1297
|
+
runId: normalizedRunId || null,
|
|
1298
|
+
origin: "thinking",
|
|
1299
|
+
phase: "update",
|
|
1300
|
+
summary: extracted.label,
|
|
1301
|
+
thinking: extracted.detail,
|
|
1302
|
+
thinkingSummarySource: extracted.thinkingSummarySource,
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// --- Internal: handshake ---
|
|
1307
|
+
|
|
1308
|
+
_sendConnect() {
|
|
1309
|
+
if (this._connectSent) return;
|
|
1310
|
+
this._connectSent = true;
|
|
1311
|
+
|
|
1312
|
+
if (this._connectTimer) {
|
|
1313
|
+
clearTimeout(this._connectTimer);
|
|
1314
|
+
this._connectTimer = null;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const identity = this._identity;
|
|
1318
|
+
if (!identity) {
|
|
1319
|
+
this.emit("error", new Error("no device identity"));
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Choose auth token: prefer cached device token, fall back to gateway token
|
|
1324
|
+
const authToken = this._deviceToken || this._gatewayToken || undefined;
|
|
1325
|
+
const canFallback = Boolean(this._deviceToken && this._gatewayToken);
|
|
1326
|
+
|
|
1327
|
+
const signedAtMs = Date.now();
|
|
1328
|
+
const nonce = this._connectNonce || undefined;
|
|
1329
|
+
|
|
1330
|
+
// Build device auth payload
|
|
1331
|
+
const payload = buildDeviceAuthPayload({
|
|
1332
|
+
deviceId: identity.deviceId,
|
|
1333
|
+
clientId: CLIENT_ID,
|
|
1334
|
+
clientMode: CLIENT_MODE,
|
|
1335
|
+
role: ROLE,
|
|
1336
|
+
scopes: SCOPES,
|
|
1337
|
+
signedAtMs,
|
|
1338
|
+
token: authToken || null,
|
|
1339
|
+
nonce,
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
// Sign with Ed25519 private key
|
|
1343
|
+
const signature = signPayload(identity.privateKeyPem, payload);
|
|
1344
|
+
|
|
1345
|
+
// Build connect request params
|
|
1346
|
+
const params = {
|
|
1347
|
+
minProtocol: PROTOCOL_VERSION,
|
|
1348
|
+
maxProtocol: PROTOCOL_VERSION,
|
|
1349
|
+
client: {
|
|
1350
|
+
id: CLIENT_ID,
|
|
1351
|
+
version: CLIENT_VERSION,
|
|
1352
|
+
platform: process.platform,
|
|
1353
|
+
mode: CLIENT_MODE,
|
|
1354
|
+
},
|
|
1355
|
+
role: ROLE,
|
|
1356
|
+
scopes: SCOPES,
|
|
1357
|
+
caps: ["tool-events"],
|
|
1358
|
+
auth: authToken ? { token: authToken } : undefined,
|
|
1359
|
+
device: {
|
|
1360
|
+
id: identity.deviceId,
|
|
1361
|
+
publicKey: publicKeyRawBase64Url(identity.publicKeyPem),
|
|
1362
|
+
signature,
|
|
1363
|
+
signedAt: signedAtMs,
|
|
1364
|
+
nonce,
|
|
1365
|
+
},
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
this._logger.info("[openclaw] Sending connect request...");
|
|
1369
|
+
|
|
1370
|
+
this.request("connect", params)
|
|
1371
|
+
.then((helloOk) => {
|
|
1372
|
+
this._logger.info(
|
|
1373
|
+
`[openclaw] Connected! protocol=${helloOk.protocol}, ` +
|
|
1374
|
+
`tick=${helloOk.policy && helloOk.policy.tickIntervalMs}ms`
|
|
1375
|
+
);
|
|
1376
|
+
|
|
1377
|
+
// Reset backoff on successful connect (step 7)
|
|
1378
|
+
this._backoffMs = 1000;
|
|
1379
|
+
|
|
1380
|
+
// Cache tick interval
|
|
1381
|
+
if (helloOk.policy && typeof helloOk.policy.tickIntervalMs === "number") {
|
|
1382
|
+
this._tickIntervalMs = helloOk.policy.tickIntervalMs;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Start tick watch (step 7)
|
|
1386
|
+
this._lastTick = Date.now();
|
|
1387
|
+
this._startTickWatch();
|
|
1388
|
+
|
|
1389
|
+
// Cache device token if provided
|
|
1390
|
+
if (helloOk.auth && helloOk.auth.deviceToken) {
|
|
1391
|
+
this._deviceToken = helloOk.auth.deviceToken;
|
|
1392
|
+
storeDeviceToken(
|
|
1393
|
+
identity.deviceId,
|
|
1394
|
+
helloOk.auth.deviceToken,
|
|
1395
|
+
helloOk.auth.role || ROLE,
|
|
1396
|
+
helloOk.auth.scopes || SCOPES,
|
|
1397
|
+
this._persistencePaths,
|
|
1398
|
+
);
|
|
1399
|
+
this._logger.info("[openclaw] Device token cached");
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
this.emit("connected", {
|
|
1403
|
+
protocol: helloOk.protocol,
|
|
1404
|
+
tickIntervalMs: this._tickIntervalMs,
|
|
1405
|
+
});
|
|
1406
|
+
this.emit("status", "connected");
|
|
1407
|
+
|
|
1408
|
+
// Post-connect: fetch agent identity (step 6) and chat history (step 9)
|
|
1409
|
+
this._postConnect().catch((err) => {
|
|
1410
|
+
this._logger.error(`[openclaw] Post-connect setup failed: ${err.message}`);
|
|
1411
|
+
this.emit("error", err);
|
|
1412
|
+
});
|
|
1413
|
+
})
|
|
1414
|
+
.catch((err) => {
|
|
1415
|
+
this._logger.error(`[openclaw] Connect failed: ${err.message}`);
|
|
1416
|
+
|
|
1417
|
+
// If we were using a cached device token and have a fallback, clear and retry
|
|
1418
|
+
if (canFallback) {
|
|
1419
|
+
this._logger.info(
|
|
1420
|
+
"[openclaw] Clearing cached device token, will use gateway token on next connect"
|
|
1421
|
+
);
|
|
1422
|
+
this._deviceToken = null;
|
|
1423
|
+
clearDeviceToken(this._persistencePaths);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
this.emit("error", err);
|
|
1427
|
+
if (this._ws) {
|
|
1428
|
+
this._ws.close(1008, "connect failed");
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// --- Internal: post-connect setup (steps 6, 9) ---
|
|
1434
|
+
|
|
1435
|
+
async _postConnect() {
|
|
1436
|
+
// Fetch agent identity (step 6) — non-blocking, don't gate on this
|
|
1437
|
+
this.fetchAgentIdentity().catch((err) => {
|
|
1438
|
+
this._logger.error(`[openclaw] Agent identity fetch failed: ${err.message}`);
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
// Fetch chat history (step 9) — blocks agent event processing until done
|
|
1442
|
+
try {
|
|
1443
|
+
await this._fetchHistory("main");
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
this._logger.error(`[openclaw] Chat history fetch failed: ${err.message}`);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Mark history as resolved and drain queued events
|
|
1449
|
+
this._historyResolved = true;
|
|
1450
|
+
this._drainEventQueue();
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// --- Internal: chat history (step 9) ---
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Fetch chat history from the gateway.
|
|
1457
|
+
* @param {string} sessionKey
|
|
1458
|
+
*/
|
|
1459
|
+
async _fetchHistory(sessionKey) {
|
|
1460
|
+
const result = await this.request("chat.history", {
|
|
1461
|
+
sessionKey,
|
|
1462
|
+
limit: 200,
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
const messages = result && Array.isArray(result.messages) ? result.messages : [];
|
|
1466
|
+
this._logger.info(`[openclaw] Chat history loaded: ${messages.length} messages`);
|
|
1467
|
+
|
|
1468
|
+
this.emit("history", {
|
|
1469
|
+
sessionKey: (result && result.sessionKey) || sessionKey,
|
|
1470
|
+
messages,
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
return result;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Drain queued agent events that arrived before history resolved.
|
|
1478
|
+
*/
|
|
1479
|
+
_drainEventQueue() {
|
|
1480
|
+
const queue = this._eventQueue;
|
|
1481
|
+
this._eventQueue = [];
|
|
1482
|
+
for (const evt of queue) {
|
|
1483
|
+
this._handleAgentEvent(evt.payload);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// --- Internal: reconnection (step 7) ---
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Schedule a reconnect attempt with exponential backoff.
|
|
1491
|
+
* @param {number} [delayOverride] - Override the backoff delay (e.g., for shutdown events)
|
|
1492
|
+
*/
|
|
1493
|
+
_scheduleReconnect(delayOverride) {
|
|
1494
|
+
if (this._stopped) return;
|
|
1495
|
+
|
|
1496
|
+
const delay = typeof delayOverride === "number" ? delayOverride : this._backoffMs;
|
|
1497
|
+
|
|
1498
|
+
// Advance backoff for next time (unless overridden)
|
|
1499
|
+
if (typeof delayOverride !== "number") {
|
|
1500
|
+
this._backoffMs = Math.min(this._backoffMs * 2, 30000);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
this._logger.info(
|
|
1504
|
+
`[openclaw] Reconnecting in ${delay}ms (backoff: ${this._backoffMs}ms)`
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
if (this._reconnectTimer) {
|
|
1508
|
+
clearTimeout(this._reconnectTimer);
|
|
1509
|
+
}
|
|
1510
|
+
this._reconnectTimer = setTimeout(() => {
|
|
1511
|
+
this._reconnectTimer = null;
|
|
1512
|
+
this.start();
|
|
1513
|
+
}, delay);
|
|
1514
|
+
// Don't prevent process exit while waiting to reconnect
|
|
1515
|
+
if (this._reconnectTimer.unref) {
|
|
1516
|
+
this._reconnectTimer.unref();
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// --- Internal: tick watch (step 7) ---
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Start watching for stale connections via tick timeout.
|
|
1524
|
+
* If no tick received within 2x tickIntervalMs, close and reconnect.
|
|
1525
|
+
*/
|
|
1526
|
+
_startTickWatch() {
|
|
1527
|
+
this._stopTickWatch();
|
|
1528
|
+
const interval = Math.max(this._tickIntervalMs, 1000);
|
|
1529
|
+
this._tickWatchTimer = setInterval(() => {
|
|
1530
|
+
if (this._stopped) return;
|
|
1531
|
+
if (!this._lastTick) return;
|
|
1532
|
+
const elapsed = Date.now() - this._lastTick;
|
|
1533
|
+
if (elapsed > this._tickIntervalMs * 2) {
|
|
1534
|
+
this._logger.warn(
|
|
1535
|
+
`[openclaw] Tick timeout (${elapsed}ms since last tick), closing connection`
|
|
1536
|
+
);
|
|
1537
|
+
if (this._ws) {
|
|
1538
|
+
this._ws.close(4000, "tick timeout");
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}, interval);
|
|
1542
|
+
// Don't prevent process exit
|
|
1543
|
+
if (this._tickWatchTimer.unref) {
|
|
1544
|
+
this._tickWatchTimer.unref();
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* Stop the tick watch timer.
|
|
1550
|
+
*/
|
|
1551
|
+
_stopTickWatch() {
|
|
1552
|
+
if (this._tickWatchTimer) {
|
|
1553
|
+
clearInterval(this._tickWatchTimer);
|
|
1554
|
+
this._tickWatchTimer = null;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// --- Internal: helpers ---
|
|
1559
|
+
|
|
1560
|
+
_flushPendingErrors(err) {
|
|
1561
|
+
for (const [, pending] of this._pending) {
|
|
1562
|
+
pending.reject(err);
|
|
1563
|
+
}
|
|
1564
|
+
this._pending.clear();
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
export function createPluginOpenclawClient(opts = {}) {
|
|
1569
|
+
return new OpenClawClient(opts);
|
|
1570
|
+
}
|