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.
@@ -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
+ }