@geravant/sinain 1.0.19 → 1.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.
Files changed (73) hide show
  1. package/README.md +10 -1
  2. package/cli.js +176 -0
  3. package/install.js +11 -2
  4. package/launcher.js +622 -0
  5. package/openclaw.plugin.json +4 -0
  6. package/pack-prepare.js +48 -0
  7. package/package.json +24 -5
  8. package/sense_client/README.md +82 -0
  9. package/sense_client/__init__.py +1 -0
  10. package/sense_client/__main__.py +462 -0
  11. package/sense_client/app_detector.py +54 -0
  12. package/sense_client/app_detector_win.py +83 -0
  13. package/sense_client/capture.py +215 -0
  14. package/sense_client/capture_win.py +88 -0
  15. package/sense_client/change_detector.py +86 -0
  16. package/sense_client/config.py +64 -0
  17. package/sense_client/gate.py +145 -0
  18. package/sense_client/ocr.py +347 -0
  19. package/sense_client/privacy.py +65 -0
  20. package/sense_client/requirements.txt +13 -0
  21. package/sense_client/roi_extractor.py +84 -0
  22. package/sense_client/sender.py +173 -0
  23. package/sense_client/tests/__init__.py +0 -0
  24. package/sense_client/tests/test_stream1_optimizations.py +234 -0
  25. package/setup-overlay.js +82 -0
  26. package/sinain-agent/.env.example +17 -0
  27. package/sinain-agent/CLAUDE.md +80 -0
  28. package/sinain-agent/mcp-config.json +12 -0
  29. package/sinain-agent/run.sh +248 -0
  30. package/sinain-core/.env.example +93 -0
  31. package/sinain-core/package-lock.json +552 -0
  32. package/sinain-core/package.json +21 -0
  33. package/sinain-core/src/agent/analyzer.ts +366 -0
  34. package/sinain-core/src/agent/context-window.ts +172 -0
  35. package/sinain-core/src/agent/loop.ts +404 -0
  36. package/sinain-core/src/agent/situation-writer.ts +187 -0
  37. package/sinain-core/src/agent/traits.ts +520 -0
  38. package/sinain-core/src/audio/capture-spawner-macos.ts +44 -0
  39. package/sinain-core/src/audio/capture-spawner-win.ts +37 -0
  40. package/sinain-core/src/audio/capture-spawner.ts +14 -0
  41. package/sinain-core/src/audio/pipeline.ts +335 -0
  42. package/sinain-core/src/audio/transcription-local.ts +141 -0
  43. package/sinain-core/src/audio/transcription.ts +278 -0
  44. package/sinain-core/src/buffers/feed-buffer.ts +71 -0
  45. package/sinain-core/src/buffers/sense-buffer.ts +425 -0
  46. package/sinain-core/src/config.ts +245 -0
  47. package/sinain-core/src/escalation/escalation-slot.ts +136 -0
  48. package/sinain-core/src/escalation/escalator.ts +812 -0
  49. package/sinain-core/src/escalation/message-builder.ts +323 -0
  50. package/sinain-core/src/escalation/openclaw-ws.ts +726 -0
  51. package/sinain-core/src/escalation/scorer.ts +166 -0
  52. package/sinain-core/src/index.ts +507 -0
  53. package/sinain-core/src/learning/feedback-store.ts +253 -0
  54. package/sinain-core/src/learning/signal-collector.ts +218 -0
  55. package/sinain-core/src/log.ts +24 -0
  56. package/sinain-core/src/overlay/commands.ts +126 -0
  57. package/sinain-core/src/overlay/ws-handler.ts +267 -0
  58. package/sinain-core/src/privacy/index.ts +18 -0
  59. package/sinain-core/src/privacy/presets.ts +40 -0
  60. package/sinain-core/src/privacy/redact.ts +92 -0
  61. package/sinain-core/src/profiler.ts +181 -0
  62. package/sinain-core/src/recorder.ts +186 -0
  63. package/sinain-core/src/server.ts +417 -0
  64. package/sinain-core/src/trace/trace-store.ts +73 -0
  65. package/sinain-core/src/trace/tracer.ts +94 -0
  66. package/sinain-core/src/types.ts +427 -0
  67. package/sinain-core/src/util/dedup.ts +48 -0
  68. package/sinain-core/src/util/task-store.ts +84 -0
  69. package/sinain-core/tsconfig.json +18 -0
  70. package/sinain-knowledge/data/git-store.ts +2 -0
  71. package/sinain-mcp-server/index.ts +337 -0
  72. package/sinain-mcp-server/package.json +19 -0
  73. package/sinain-mcp-server/tsconfig.json +15 -0
@@ -0,0 +1,726 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign } from "node:crypto";
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import WebSocket from "ws";
7
+ import type { OpenClawConfig } from "../types.js";
8
+ import { log, warn, error } from "../log.js";
9
+
10
+ const TAG = "openclaw";
11
+
12
+ // ── Device Identity ──────────────────────────────────────────────────────────
13
+
14
+ interface DeviceIdentity {
15
+ deviceId: string;
16
+ publicKeyPem: string;
17
+ privateKeyPem: string;
18
+ }
19
+
20
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
21
+
22
+ function base64UrlEncode(buf: Buffer): string {
23
+ return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
24
+ }
25
+
26
+ function derivePublicKeyRaw(publicKeyPem: string): Buffer {
27
+ const spki = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
28
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
29
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
30
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
31
+ }
32
+ return spki;
33
+ }
34
+
35
+ function fingerprintPublicKey(publicKeyPem: string): string {
36
+ return createHash("sha256").update(derivePublicKeyRaw(publicKeyPem)).digest("hex");
37
+ }
38
+
39
+ function publicKeyRawBase64Url(publicKeyPem: string): string {
40
+ return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
41
+ }
42
+
43
+ function signDevicePayload(privateKeyPem: string, payload: string): string {
44
+ const key = createPrivateKey(privateKeyPem);
45
+ return base64UrlEncode(sign(null, Buffer.from(payload, "utf8"), key) as unknown as Buffer);
46
+ }
47
+
48
+ function buildDeviceAuthPayloadV3(params: {
49
+ deviceId: string; clientId: string; clientMode: string;
50
+ role: string; scopes: string[]; signedAtMs: number;
51
+ token: string | null; nonce: string; platform: string;
52
+ }): string {
53
+ return [
54
+ "v3", params.deviceId, params.clientId, params.clientMode,
55
+ params.role, params.scopes.join(","), String(params.signedAtMs),
56
+ params.token ?? "", params.nonce, params.platform.toLowerCase(), "",
57
+ ].join("|");
58
+ }
59
+
60
+ const DEVICE_IDENTITY_PATH = join(homedir(), ".sinain", "device-identity.json");
61
+
62
+ function loadOrCreateDeviceIdentity(): DeviceIdentity {
63
+ try {
64
+ if (existsSync(DEVICE_IDENTITY_PATH)) {
65
+ const parsed = JSON.parse(readFileSync(DEVICE_IDENTITY_PATH, "utf8"));
66
+ if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) {
67
+ const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
68
+ return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
69
+ }
70
+ }
71
+ } catch {}
72
+
73
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
74
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }) as string;
75
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }) as string;
76
+ const deviceId = fingerprintPublicKey(publicKeyPem);
77
+
78
+ const dir = dirname(DEVICE_IDENTITY_PATH);
79
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
80
+ writeFileSync(DEVICE_IDENTITY_PATH, JSON.stringify({ version: 1, deviceId, publicKeyPem, privateKeyPem }, null, 2) + "\n", { mode: 0o600 });
81
+ log(TAG, `generated device identity → ${deviceId.slice(0, 12)}… (${DEVICE_IDENTITY_PATH})`);
82
+
83
+ return { deviceId, publicKeyPem, privateKeyPem };
84
+ }
85
+
86
+ /** Ping timeout — if no pong arrives within this window after a ping, terminate. */
87
+ const PING_TIMEOUT_MS = 5_000;
88
+
89
+ interface PendingRpc {
90
+ method: string;
91
+ resolve: (value: any) => void;
92
+ reject: (reason: any) => void;
93
+ timeout: ReturnType<typeof setTimeout>;
94
+ expectFinal: boolean;
95
+ sentAt: number;
96
+ // For split RPCs (sendAgentRpcSplit):
97
+ isSplit?: boolean;
98
+ acceptedResolve?: () => void;
99
+ acceptedReject?: (err: any) => void;
100
+ finalResolve?: (value: any) => void;
101
+ finalReject?: (reason: any) => void;
102
+ }
103
+
104
+ interface PendingFinalRpc {
105
+ finalResolve: (value: any) => void;
106
+ finalReject: (reason: any) => void;
107
+ finalTimeout: ReturnType<typeof setTimeout>;
108
+ sentAt: number;
109
+ }
110
+
111
+ /**
112
+ * Persistent WebSocket client to OpenClaw gateway.
113
+ * Ported from relay with added circuit breaker and exponential backoff.
114
+ *
115
+ * Protocol:
116
+ * 1. Server sends connect.challenge → client responds with connect + auth token
117
+ * 2. Client sends 'agent' RPC → server responds with two-frame protocol (accepted + final)
118
+ * 3. Client extracts text from payload.result.payloads[].text
119
+ *
120
+ * Split RPC protocol (sendAgentRpcSplit):
121
+ * Phase 1 (10s timeout): await accepted frame → blocks queue worker
122
+ * Phase 2 (120s timeout): final frame → resolved async, never trips circuit
123
+ */
124
+ export class OpenClawWsClient extends EventEmitter {
125
+ private ws: WebSocket | null = null;
126
+ private authenticated = false;
127
+ private deviceIdentity: DeviceIdentity;
128
+ private rpcId = 1;
129
+ private pending = new Map<string, PendingRpc>();
130
+ /** Phase 2 of split RPCs — resolved by final frame, rejected by 120s timeout or disconnect. */
131
+ private pendingFinal = new Map<string, PendingFinalRpc>();
132
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
133
+ private stopped = false;
134
+
135
+ // Exponential backoff
136
+ private reconnectDelay = 1000;
137
+ private maxReconnectDelay = 60000;
138
+
139
+ // Circuit breaker (time-window based)
140
+ private recentFailures: number[] = []; // timestamps of recent failures
141
+ private circuitOpen = false;
142
+ private circuitResetTimer: ReturnType<typeof setTimeout> | null = null;
143
+ private static readonly CIRCUIT_THRESHOLD = 5;
144
+ private static readonly CIRCUIT_WINDOW_MS = 2 * 60 * 1000; // 2-minute sliding window
145
+ private circuitResetDelay = 300_000; // starts at 5 min, doubles on each trip
146
+ private readonly MAX_CIRCUIT_RESET = 1_800_000; // caps at 30 min
147
+
148
+ // Connection attempt counter for diagnostics
149
+ private connectAttempts = 0;
150
+ private connectedAt: number | null = null;
151
+
152
+ // WS ping keepalive
153
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
154
+ private pingTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
155
+
156
+ constructor(private config: OpenClawConfig) {
157
+ super();
158
+ this.deviceIdentity = loadOrCreateDeviceIdentity();
159
+ log(TAG, `device identity: ${this.deviceIdentity.deviceId.slice(0, 12)}…`);
160
+ }
161
+
162
+ /** Connect to the OpenClaw gateway. */
163
+ connect(): void {
164
+ if (!this.config.gatewayToken && !this.config.hookUrl) {
165
+ log(TAG, "connect: no gateway token or hookUrl — skipping");
166
+ return;
167
+ }
168
+ if (this.stopped) {
169
+ log(TAG, "connect: stopped — skipping");
170
+ return;
171
+ }
172
+ if (this.circuitOpen) {
173
+ log(TAG, "connect: circuit breaker open — skipping");
174
+ return;
175
+ }
176
+
177
+ // If a ws instance exists but is in a non-usable state, terminate it cleanly
178
+ // before creating a new one. This prevents the circuit-reset path from being
179
+ // blocked by a stale CLOSING/CLOSED socket that hasn't triggered cleanup yet.
180
+ if (this.ws) {
181
+ const state = this.ws.readyState;
182
+ if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
183
+ log(TAG, `connect: already ${state === WebSocket.OPEN ? "open" : "connecting"} — skipping`);
184
+ return;
185
+ }
186
+ log(TAG, `connect: terminating stale socket (readyState=${state})`);
187
+ try { this.ws.terminate(); } catch {}
188
+ this.ws = null;
189
+ this.authenticated = false;
190
+ }
191
+
192
+ this.connectAttempts++;
193
+ const wsUrl = this.config.gatewayWsUrl;
194
+ log(TAG, `connect: attempt #${this.connectAttempts} → ${wsUrl}`);
195
+
196
+ try {
197
+ this.ws = new WebSocket(wsUrl);
198
+
199
+ this.ws.on("open", () => {
200
+ log(TAG, `ws open (attempt #${this.connectAttempts}), awaiting challenge...`);
201
+ });
202
+
203
+ this.ws.on("message", (raw) => {
204
+ try {
205
+ const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString());
206
+ this.handleMessage(msg);
207
+ } catch (err: any) {
208
+ error(TAG, "ws message parse error:", err.message);
209
+ }
210
+ });
211
+
212
+ this.ws.on("pong", () => {
213
+ if (this.pingTimeoutTimer) {
214
+ clearTimeout(this.pingTimeoutTimer);
215
+ this.pingTimeoutTimer = null;
216
+ }
217
+ });
218
+
219
+ this.ws.on("close", (code, reason) => {
220
+ const reasonStr = reason?.toString() || "";
221
+ const uptime = this.connectedAt ? `${Math.round((Date.now() - this.connectedAt) / 1000)}s uptime` : "never authenticated";
222
+ log(TAG, `ws closed: code=${code}${reasonStr ? ` reason="${reasonStr}"` : ""} (${uptime})`);
223
+ this.connectedAt = null;
224
+ this.cleanup("ws closed");
225
+ this.scheduleReconnect();
226
+ });
227
+
228
+ this.ws.on("error", (err) => {
229
+ // error event always precedes close; log it but let close handler drive cleanup
230
+ error(TAG, `ws error: ${err.message}`);
231
+ });
232
+ } catch (err: any) {
233
+ error(TAG, `connect: instantiation failed: ${err.message}`);
234
+ this.ws = null;
235
+ }
236
+ }
237
+
238
+ /** Send an agent RPC call. Returns the response payload. */
239
+ async sendAgentRpc(
240
+ message: string,
241
+ idemKey: string,
242
+ sessionKey: string,
243
+ ): Promise<any> {
244
+ if (this.circuitOpen) {
245
+ warn(TAG, "sendAgentRpc: circuit breaker open — skipping");
246
+ return null;
247
+ }
248
+ log(TAG, `sendAgentRpc: session=${sessionKey} idemKey=${idemKey} msgLen=${message.length}`);
249
+ const result = await this.sendRpc("agent", {
250
+ message,
251
+ idempotencyKey: idemKey,
252
+ sessionKey,
253
+ deliver: false,
254
+ }, 45000, { expectFinal: true });
255
+ if (result?.ok) {
256
+ this.circuitResetDelay = 300_000; // reset backoff on success
257
+ }
258
+ return result;
259
+ }
260
+
261
+ /**
262
+ * Send a split-phase agent RPC.
263
+ *
264
+ * Returns two promises:
265
+ * acceptedPromise — resolves when Phase 1 (accepted frame) arrives within 10s.
266
+ * Rejection counts as a circuit-breaker failure (real delivery failure).
267
+ * finalPromise — resolves when Phase 2 (final frame) arrives within 120s.
268
+ * Timeout/rejection does NOT trip circuit breaker (agent slowness ≠ delivery failure).
269
+ *
270
+ * Queue worker awaits acceptedPromise, then releases immediately.
271
+ * finalPromise is handled async — response arrives later.
272
+ */
273
+ sendAgentRpcSplit(
274
+ message: string,
275
+ idemKey: string,
276
+ sessionKey: string,
277
+ ): { acceptedPromise: Promise<void>; finalPromise: Promise<any> } {
278
+ let acceptedResolve!: () => void;
279
+ let acceptedReject!: (err: any) => void;
280
+ let finalResolve!: (value: any) => void;
281
+ let finalReject!: (err: any) => void;
282
+
283
+ const acceptedPromise = new Promise<void>((res, rej) => {
284
+ acceptedResolve = res;
285
+ acceptedReject = rej;
286
+ });
287
+ const finalPromise = new Promise<any>((res, rej) => {
288
+ finalResolve = res;
289
+ finalReject = rej;
290
+ });
291
+
292
+ if (this.circuitOpen) {
293
+ const err = new Error("circuit breaker open");
294
+ acceptedReject(err);
295
+ finalReject(err);
296
+ return { acceptedPromise, finalPromise };
297
+ }
298
+
299
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.authenticated) {
300
+ const reason = !this.ws ? "no socket" : !this.authenticated ? "not authenticated" : `ws state=${this.ws.readyState}`;
301
+ const err = new Error(`gateway not connected: ${reason}`);
302
+ acceptedReject(err);
303
+ finalReject(err);
304
+ return { acceptedPromise, finalPromise };
305
+ }
306
+
307
+ const id = String(this.rpcId++);
308
+ const sentAt = Date.now();
309
+ log(TAG, `sendAgentRpcSplit → id=${id} idemKey=${idemKey} msgLen=${message.length}`);
310
+
311
+ const phase1Timeout = setTimeout(() => {
312
+ if (this.pending.has(id)) {
313
+ this.pending.delete(id);
314
+ const elapsed = Date.now() - sentAt;
315
+ warn(TAG, `rpc ${id} (agent) Phase 1 TIMEOUT after ${elapsed}ms`);
316
+ this.onRpcFailure();
317
+ const err = new Error(`rpc phase1 timeout: agent`);
318
+ acceptedReject(err);
319
+ finalReject(err);
320
+ }
321
+ }, this.config.phase1TimeoutMs);
322
+
323
+ this.pending.set(id, {
324
+ method: "agent",
325
+ resolve: () => {}, // unused for split RPCs
326
+ reject: () => {}, // unused for split RPCs
327
+ timeout: phase1Timeout,
328
+ expectFinal: false, // we handle the accepted frame ourselves
329
+ sentAt,
330
+ isSplit: true,
331
+ acceptedResolve,
332
+ acceptedReject,
333
+ finalResolve,
334
+ finalReject,
335
+ });
336
+
337
+ try {
338
+ this.ws.send(JSON.stringify({
339
+ type: "req",
340
+ method: "agent",
341
+ id,
342
+ params: {
343
+ message,
344
+ idempotencyKey: idemKey,
345
+ sessionKey,
346
+ deliver: false,
347
+ },
348
+ }));
349
+ } catch (err: any) {
350
+ clearTimeout(phase1Timeout);
351
+ this.pending.delete(id);
352
+ error(TAG, `sendAgentRpcSplit: ws.send() threw: ${err.message}`);
353
+ const sendErr = new Error(`ws.send failed: ${err.message}`);
354
+ acceptedReject(sendErr);
355
+ finalReject(sendErr);
356
+ }
357
+
358
+ return { acceptedPromise, finalPromise };
359
+ }
360
+
361
+ /** Check if connected and authenticated. */
362
+ get isConnected(): boolean {
363
+ return !!(this.ws && this.ws.readyState === WebSocket.OPEN && this.authenticated);
364
+ }
365
+
366
+ /** Check if the circuit breaker is currently open. */
367
+ get isCircuitOpen(): boolean {
368
+ return this.circuitOpen;
369
+ }
370
+
371
+ /** Force reconnection — resets stopped state and reconnect delay. */
372
+ resetConnection(): void {
373
+ log(TAG, "resetConnection: clearing stopped state, reconnecting");
374
+ this.stopped = false;
375
+ this.reconnectDelay = 1000;
376
+ if (this.ws) {
377
+ try { this.ws.close(1000, "reset"); } catch {}
378
+ this.ws = null;
379
+ this.authenticated = false;
380
+ }
381
+ this.connect();
382
+ }
383
+
384
+ /** Graceful disconnect — does not schedule reconnect. */
385
+ disconnect(): void {
386
+ log(TAG, `disconnect: stopping (pending=${this.pending.size}, pendingFinal=${this.pendingFinal.size})`);
387
+ this.stopped = true;
388
+ this.stopPing();
389
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
390
+ if (this.circuitResetTimer) { clearTimeout(this.circuitResetTimer); this.circuitResetTimer = null; }
391
+ if (this.ws) { try { this.ws.close(1000, "graceful shutdown"); } catch {} this.ws = null; }
392
+ this.authenticated = false;
393
+ this.connectedAt = null;
394
+ this.rejectAllPending("disconnected");
395
+ this.rejectAllPendingFinal("disconnected");
396
+ }
397
+
398
+ // ── Private ──
399
+
400
+ private handleMessage(msg: any): void {
401
+ // Handle connect.challenge
402
+ if (msg.type === "event" && msg.event === "connect.challenge") {
403
+ const nonce: string = msg.payload?.nonce ?? msg.nonce ?? "";
404
+ const tokenHash = this.config.gatewayToken
405
+ ? createHash("sha256").update(this.config.gatewayToken).digest("hex").slice(0, 12)
406
+ : "none";
407
+ log(TAG, `received connect.challenge — sending auth (tokenHash=${tokenHash}, device=${this.deviceIdentity.deviceId.slice(0, 12)}…)`);
408
+
409
+ const scopes = ["operator.read", "operator.write", "operator.admin"];
410
+ const signedAtMs = Date.now();
411
+ const payload = buildDeviceAuthPayloadV3({
412
+ deviceId: this.deviceIdentity.deviceId,
413
+ clientId: "gateway-client",
414
+ clientMode: "backend",
415
+ role: "operator",
416
+ scopes,
417
+ signedAtMs,
418
+ token: this.config.gatewayToken || null,
419
+ nonce,
420
+ platform: process.platform,
421
+ });
422
+ const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload);
423
+
424
+ this.ws?.send(JSON.stringify({
425
+ type: "req",
426
+ id: "connect-1",
427
+ method: "connect",
428
+ params: {
429
+ minProtocol: 3,
430
+ maxProtocol: 3,
431
+ role: "operator",
432
+ scopes,
433
+ client: {
434
+ id: "gateway-client",
435
+ displayName: "Sinain Core",
436
+ version: "1.0.0",
437
+ platform: process.platform,
438
+ mode: "backend",
439
+ },
440
+ auth: { token: this.config.gatewayToken },
441
+ device: {
442
+ id: this.deviceIdentity.deviceId,
443
+ publicKey: publicKeyRawBase64Url(this.deviceIdentity.publicKeyPem),
444
+ signature,
445
+ signedAt: signedAtMs,
446
+ nonce,
447
+ },
448
+ },
449
+ }));
450
+ return;
451
+ }
452
+
453
+ // Handle connect response
454
+ if (msg.type === "res" && msg.id === "connect-1") {
455
+ if (msg.ok) {
456
+ this.authenticated = true;
457
+ this.connectedAt = Date.now();
458
+ this.reconnectDelay = 1000; // Reset backoff on successful auth
459
+ log(TAG, `authenticated ✓ (attempt #${this.connectAttempts}, reconnectDelay reset to 1s)`);
460
+ this.startPing();
461
+ this.emit("connected");
462
+ } else {
463
+ const errInfo = msg.error || msg.payload?.error || "unknown";
464
+ const authReason = msg.error?.details?.authReason
465
+ || msg.payload?.error?.details?.authReason;
466
+ error(TAG, `auth failed: ${JSON.stringify(errInfo)}`);
467
+
468
+ // Auth errors — retry with long backoff (token may become valid after gateway restart)
469
+ if (authReason === "token_mismatch") {
470
+ error(TAG, "auth failure (token_mismatch) — will retry with long backoff. Check OPENCLAW_GATEWAY_TOKEN.");
471
+ this.reconnectDelay = Math.min(this.reconnectDelay * 4, this.maxReconnectDelay);
472
+ this.ws?.close();
473
+ return;
474
+ }
475
+
476
+ log(TAG, "auth failed (transient) — closing to trigger reconnect");
477
+ this.ws?.close();
478
+ }
479
+ return;
480
+ }
481
+
482
+ // Handle RPC responses
483
+ const msgId = msg.id != null ? String(msg.id) : null;
484
+
485
+ // Check pendingFinal first — Phase 2 final frames for split RPCs
486
+ if (msg.type === "res" && msgId && this.pendingFinal.has(msgId)) {
487
+ const pf = this.pendingFinal.get(msgId)!;
488
+ clearTimeout(pf.finalTimeout);
489
+ this.pendingFinal.delete(msgId);
490
+ const elapsed = Date.now() - pf.sentAt;
491
+ if (msg.ok) {
492
+ log(TAG, `rpc ${msgId} Phase 2 final: ok in ${elapsed}ms, status=${msg.payload?.status ?? "n/a"}`);
493
+ this.circuitResetDelay = 300_000; // success — reset circuit backoff
494
+ pf.finalResolve(msg);
495
+ } else {
496
+ warn(TAG, `rpc ${msgId} Phase 2 final: error in ${elapsed}ms: ${JSON.stringify(msg.error ?? msg.payload?.error).slice(0, 200)}`);
497
+ pf.finalReject(new Error(JSON.stringify(msg.error ?? msg.payload?.error ?? "phase2 error").slice(0, 200)));
498
+ }
499
+ return;
500
+ }
501
+
502
+ if (msg.type === "res" && msgId && this.pending.has(msgId)) {
503
+ const pending = this.pending.get(msgId)!;
504
+ const elapsed = Date.now() - pending.sentAt;
505
+
506
+ // Split RPC: handle accepted frame → transition to Phase 2
507
+ if (pending.isSplit) {
508
+ clearTimeout(pending.timeout);
509
+ this.pending.delete(msgId);
510
+
511
+ if (msg.payload?.status === "accepted") {
512
+ log(TAG, `rpc ${msgId} (agent) Phase 1 accepted in ${elapsed}ms`);
513
+ const phase2Timeout = setTimeout(() => {
514
+ if (this.pendingFinal.has(msgId)) {
515
+ const pf = this.pendingFinal.get(msgId)!;
516
+ this.pendingFinal.delete(msgId);
517
+ warn(TAG, `rpc ${msgId} Phase 2 TIMEOUT (${Date.now() - pf.sentAt}ms) — agent slow, unblocking`);
518
+ pf.finalReject(new Error("rpc phase2 timeout: agent"));
519
+ // Intentionally NOT calling onRpcFailure() — agent slowness ≠ delivery failure
520
+ }
521
+ }, this.config.phase2TimeoutMs);
522
+ this.pendingFinal.set(msgId, {
523
+ finalResolve: pending.finalResolve!,
524
+ finalReject: pending.finalReject!,
525
+ finalTimeout: phase2Timeout,
526
+ sentAt: pending.sentAt,
527
+ });
528
+ pending.acceptedResolve!();
529
+ } else {
530
+ // Phase 1 error response (not accepted)
531
+ warn(TAG, `rpc ${msgId} (agent) Phase 1 error in ${elapsed}ms: ${JSON.stringify(msg.error ?? msg.payload?.error).slice(0, 200)}`);
532
+ this.onRpcFailure();
533
+ const err = new Error(JSON.stringify(msg.error ?? msg.payload?.error ?? "phase1 error").slice(0, 200));
534
+ pending.acceptedReject!(err);
535
+ pending.finalReject!(err);
536
+ }
537
+ return;
538
+ }
539
+
540
+ // Regular RPC: skip intermediate "accepted" frame when expecting final
541
+ if (pending.expectFinal && msg.payload?.status === "accepted") {
542
+ log(TAG, `rpc ${msgId} (${pending.method}): accepted after ${elapsed}ms, waiting for final`);
543
+ return;
544
+ }
545
+
546
+ clearTimeout(pending.timeout);
547
+ this.pending.delete(msgId);
548
+
549
+ if (msg.ok) {
550
+ log(TAG, `rpc ${msgId} (${pending.method}): ok in ${elapsed}ms, status=${msg.payload?.status ?? "n/a"}`);
551
+ } else {
552
+ warn(TAG, `rpc ${msgId} (${pending.method}): error in ${elapsed}ms: ${JSON.stringify(msg.error ?? msg.payload?.error).slice(0, 200)}`);
553
+ }
554
+
555
+ pending.resolve(msg);
556
+ return;
557
+ }
558
+
559
+ // Unmatched response — might be for a pending that was already timed out
560
+ if (msg.type === "res" && msgId) {
561
+ warn(TAG, `rpc ${msgId}: received response for unknown/expired pending call`);
562
+ }
563
+ }
564
+
565
+ /** Send a generic RPC call. Returns the response. */
566
+ sendRpc(
567
+ method: string,
568
+ params: Record<string, unknown>,
569
+ timeoutMs = 90000,
570
+ opts: { expectFinal?: boolean } = {},
571
+ ): Promise<any> {
572
+ return new Promise((resolve, reject) => {
573
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.authenticated) {
574
+ const reason = !this.ws ? "no socket" : !this.authenticated ? "not authenticated" : `ws state=${this.ws.readyState}`;
575
+ warn(TAG, `sendRpc(${method}): not ready — ${reason}`);
576
+ reject(new Error(`gateway not connected: ${reason}`));
577
+ return;
578
+ }
579
+
580
+ const id = String(this.rpcId++);
581
+ const sentAt = Date.now();
582
+ log(TAG, `sendRpc → id=${id} method=${method} timeout=${timeoutMs}ms pending=${this.pending.size + 1}`);
583
+
584
+ const timeout = setTimeout(() => {
585
+ if (this.pending.has(id)) {
586
+ this.pending.delete(id);
587
+ const elapsed = Date.now() - sentAt;
588
+ warn(TAG, `rpc ${id} (${method}): TIMEOUT after ${elapsed}ms`);
589
+ this.onRpcFailure();
590
+ reject(new Error(`rpc timeout: ${method}`));
591
+ }
592
+ }, timeoutMs);
593
+
594
+ this.pending.set(id, {
595
+ method,
596
+ resolve,
597
+ reject: (reason) => { this.onRpcFailure(); reject(reason); },
598
+ timeout,
599
+ expectFinal: !!opts.expectFinal,
600
+ sentAt,
601
+ });
602
+
603
+ try {
604
+ this.ws.send(JSON.stringify({ type: "req", method, id, params }));
605
+ } catch (err: any) {
606
+ // send() threw synchronously — connection is broken
607
+ clearTimeout(timeout);
608
+ this.pending.delete(id);
609
+ error(TAG, `sendRpc(${method}): ws.send() threw: ${err.message}`);
610
+ reject(new Error(`ws.send failed: ${err.message}`));
611
+ }
612
+ });
613
+ }
614
+
615
+ private startPing(): void {
616
+ this.stopPing();
617
+ if (!this.config.pingIntervalMs || this.config.pingIntervalMs <= 0) return;
618
+ this.pingTimer = setInterval(() => {
619
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
620
+ this.pingTimeoutTimer = setTimeout(() => {
621
+ warn(TAG, "ping timeout — terminating connection");
622
+ this.ws?.terminate();
623
+ }, PING_TIMEOUT_MS);
624
+ this.ws.ping();
625
+ }, this.config.pingIntervalMs);
626
+ }
627
+
628
+ private stopPing(): void {
629
+ if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
630
+ if (this.pingTimeoutTimer) { clearTimeout(this.pingTimeoutTimer); this.pingTimeoutTimer = null; }
631
+ }
632
+
633
+ private cleanup(reason: string): void {
634
+ this.stopPing();
635
+ const pendingCount = this.pending.size;
636
+ const finalCount = this.pendingFinal.size;
637
+ this.ws = null;
638
+ this.authenticated = false;
639
+ if (pendingCount > 0 || finalCount > 0) {
640
+ log(TAG, `cleanup (${reason}): rejecting ${pendingCount} pending, ${finalCount} pendingFinal RPCs`);
641
+ this.rejectAllPending(`gateway disconnected: ${reason}`);
642
+ // Phase 2 rejections — no onRpcFailure() (agent slowness ≠ connection failure)
643
+ this.rejectAllPendingFinal(`gateway disconnected: ${reason}`);
644
+ }
645
+ }
646
+
647
+ private rejectAllPending(reason: string): void {
648
+ for (const [id, pending] of this.pending) {
649
+ clearTimeout(pending.timeout);
650
+ log(TAG, ` rejecting pending rpc ${id} (${pending.method}), was pending ${Date.now() - pending.sentAt}ms`);
651
+ if (pending.isSplit) {
652
+ // Phase 1 disconnect = real delivery failure → count for circuit breaker
653
+ this.onRpcFailure();
654
+ const err = new Error(reason);
655
+ pending.acceptedReject?.(err);
656
+ pending.finalReject?.(err);
657
+ } else {
658
+ pending.reject(new Error(reason));
659
+ }
660
+ }
661
+ this.pending.clear();
662
+ }
663
+
664
+ /** Reject all Phase 2 pending RPCs. Does NOT call onRpcFailure(). */
665
+ private rejectAllPendingFinal(reason: string): void {
666
+ for (const [id, pf] of this.pendingFinal) {
667
+ clearTimeout(pf.finalTimeout);
668
+ log(TAG, ` rejecting pendingFinal rpc ${id}, was pending ${Date.now() - pf.sentAt}ms`);
669
+ pf.finalReject(new Error(reason));
670
+ }
671
+ this.pendingFinal.clear();
672
+ }
673
+
674
+ private scheduleReconnect(): void {
675
+ if (this.stopped) {
676
+ log(TAG, "scheduleReconnect: stopped — not scheduling");
677
+ return;
678
+ }
679
+ if (this.reconnectTimer) {
680
+ log(TAG, "scheduleReconnect: already scheduled");
681
+ return;
682
+ }
683
+ if (this.circuitOpen) {
684
+ log(TAG, "scheduleReconnect: circuit open — deferring to circuit reset");
685
+ return;
686
+ }
687
+ log(TAG, `scheduleReconnect: in ${this.reconnectDelay}ms (backoff=${this.reconnectDelay}ms)`);
688
+ this.reconnectTimer = setTimeout(() => {
689
+ this.reconnectTimer = null;
690
+ log(TAG, "scheduleReconnect: firing — calling connect()");
691
+ this.connect();
692
+ }, this.reconnectDelay);
693
+ // Exponential backoff
694
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
695
+ }
696
+
697
+ private onRpcFailure(): void {
698
+ const now = Date.now();
699
+ this.recentFailures.push(now);
700
+
701
+ // Trim entries outside the sliding window
702
+ const cutoff = now - OpenClawWsClient.CIRCUIT_WINDOW_MS;
703
+ this.recentFailures = this.recentFailures.filter(ts => ts > cutoff);
704
+
705
+ if (this.recentFailures.length >= 3) {
706
+ warn(TAG, `${this.recentFailures.length} RPC failures in last ${OpenClawWsClient.CIRCUIT_WINDOW_MS / 1000}s (threshold: ${OpenClawWsClient.CIRCUIT_THRESHOLD})`);
707
+ }
708
+
709
+ if (this.recentFailures.length >= OpenClawWsClient.CIRCUIT_THRESHOLD && !this.circuitOpen) {
710
+ this.circuitOpen = true;
711
+ // Add 0-30s random jitter to prevent thundering herd on service recovery
712
+ const jitterMs = Math.floor(Math.random() * 30000);
713
+ const resetDelayMs = this.circuitResetDelay + jitterMs;
714
+ warn(TAG, `circuit breaker OPENED after ${this.recentFailures.length} failures — pausing ${Math.round(resetDelayMs / 1000)}s (next reset: ${Math.round(Math.min(this.circuitResetDelay * 2, this.MAX_CIRCUIT_RESET) / 1000)}s)`);
715
+ this.circuitResetTimer = setTimeout(() => {
716
+ this.circuitResetTimer = null;
717
+ this.circuitOpen = false;
718
+ this.recentFailures = [];
719
+ log(TAG, "circuit breaker RESET — calling connect()");
720
+ this.connect();
721
+ }, resetDelayMs);
722
+ // Progressive backoff: double the delay for next trip, capped at MAX
723
+ this.circuitResetDelay = Math.min(this.circuitResetDelay * 2, this.MAX_CIRCUIT_RESET);
724
+ }
725
+ }
726
+ }