@pinclaw/pinclaw-plugin 0.1.24

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/index.mjs ADDED
@@ -0,0 +1,1945 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import process from "node:process";
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const relayCapabilities = ["host-enrollment", "desktop-binding", "relay-session"];
10
+ const relayEvents = ["chat", "agent", "presence", "health"];
11
+ const defaultHostHeartbeatIntervalMs = 10_000;
12
+ const pinClawChannelId = "pinclaw";
13
+ const defaultPinClawAccountId = "default";
14
+ const emptyPluginConfigSchema = {
15
+ type: "object",
16
+ properties: {},
17
+ additionalProperties: false,
18
+ };
19
+ const pinClawChannelConfigSchema = {
20
+ schema: {
21
+ type: "object",
22
+ properties: {
23
+ enabled: {
24
+ type: "boolean",
25
+ },
26
+ name: {
27
+ type: "string",
28
+ },
29
+ },
30
+ additionalProperties: false,
31
+ },
32
+ };
33
+ const pinClawChannelMeta = {
34
+ id: pinClawChannelId,
35
+ label: "PinClaw",
36
+ selectionLabel: "PinClaw Relay",
37
+ detailLabel: "PinClaw Host Relay",
38
+ docsPath: "/channels/pinclaw",
39
+ docsLabel: "pinclaw",
40
+ blurb: "Relay-hosted desktop access channel for PinClaw.",
41
+ systemImage: "desktopcomputer",
42
+ };
43
+ const openClawLookupPaths = [
44
+ process.cwd(),
45
+ "/opt/homebrew/lib/node_modules",
46
+ "/usr/lib/node_modules",
47
+ "/usr/local/lib/node_modules",
48
+ ];
49
+ let replyRuntimePromise;
50
+ const activePinClawChannelAccounts = new Map();
51
+
52
+ export function createRelayServiceSnapshot() {
53
+ return {
54
+ serviceId: "pinclaw.relay",
55
+ name: "PinClaw Relay Service",
56
+ capabilities: relayCapabilities,
57
+ hostEnrollment: {
58
+ activeEnrollmentId: "enr-active-8246",
59
+ activeToken: "pct_enr_8246",
60
+ relayEndpoint: "wss://relay-manager.example/ws/hosts/host_mac_mini",
61
+ expiresAt: "2026-03-26T10:30:00.000Z",
62
+ consumedEnrollmentIds: ["enr-consumed-7001"],
63
+ },
64
+ desktopBinding: {
65
+ activePairingCode: "PC-8X4N",
66
+ activeDesktopId: "desktop_macbook_pro",
67
+ replacedDesktopIds: ["desktop_ipad_mini"],
68
+ pendingDesktopIds: ["desktop_windows_dev"],
69
+ },
70
+ relaySession: {
71
+ transport: "websocket",
72
+ gatewayUrl: "wss://relay-manager.example/ws/hosts/host_mac_mini",
73
+ heartbeatIntervalMs: defaultHostHeartbeatIntervalMs,
74
+ idleTimeoutMs: 30_000,
75
+ hostId: "host_mac_mini",
76
+ activeDesktopSessionId: "desktop_macbook_pro",
77
+ replacedDesktopSessionIds: ["desktop_ipad_mini"],
78
+ },
79
+ };
80
+ }
81
+
82
+ export function createDesktopSessionRouter() {
83
+ const activeDesktopByHost = new Map();
84
+ const replacedDesktopByHost = new Map();
85
+
86
+ return {
87
+ activateDesktopSession({ hostId, desktopId }) {
88
+ const previousDesktopId = activeDesktopByHost.get(hostId);
89
+
90
+ activeDesktopByHost.set(hostId, desktopId);
91
+
92
+ if (previousDesktopId !== undefined && previousDesktopId !== desktopId) {
93
+ const replaced = replacedDesktopByHost.get(hostId) ?? [];
94
+ replacedDesktopByHost.set(hostId, [...replaced, previousDesktopId]);
95
+ }
96
+
97
+ return {
98
+ hostId,
99
+ activeDesktopId: desktopId,
100
+ replacedDesktopId:
101
+ previousDesktopId !== desktopId ? previousDesktopId : undefined,
102
+ };
103
+ },
104
+ routeDesktopRequest({ hostId, desktopId }) {
105
+ return {
106
+ accepted: activeDesktopByHost.get(hostId) === desktopId,
107
+ reason:
108
+ activeDesktopByHost.get(hostId) === desktopId
109
+ ? "accepted"
110
+ : "desktop-session-replaced",
111
+ };
112
+ },
113
+ getSnapshot(hostId) {
114
+ return {
115
+ hostId,
116
+ activeDesktopId: activeDesktopByHost.get(hostId),
117
+ replacedDesktopIds: replacedDesktopByHost.get(hostId) ?? [],
118
+ };
119
+ },
120
+ };
121
+ }
122
+
123
+ export function createHostTunnelRuntime({ transport, onDesktopRequest }) {
124
+ const router = createDesktopSessionRouter();
125
+ let connection;
126
+
127
+ return {
128
+ connect({ relayUrl, hostId, credentialSecret }) {
129
+ connection = {
130
+ hostId,
131
+ credentialSecret,
132
+ websocketUrl: relayUrl.replace(/^http/u, "ws").replace(
133
+ /\/?$/u,
134
+ `/ws/hosts/${hostId}`,
135
+ ),
136
+ };
137
+ transport.send({
138
+ type: "host.hello",
139
+ hostId,
140
+ authorization: `Bearer ${credentialSecret}`,
141
+ websocketUrl: connection.websocketUrl,
142
+ });
143
+ },
144
+ receivePlatformMessage(message) {
145
+ if (message.type === "desktop-session.current") {
146
+ router.activateDesktopSession({
147
+ hostId: message.hostId,
148
+ desktopId: message.desktopId,
149
+ });
150
+ return;
151
+ }
152
+
153
+ if (message.type === "desktop-request") {
154
+ const decision = router.routeDesktopRequest({
155
+ hostId: message.hostId,
156
+ desktopId: message.desktopId,
157
+ });
158
+
159
+ if (decision.accepted) {
160
+ onDesktopRequest(message);
161
+ }
162
+ }
163
+ },
164
+ flushHeartbeat() {
165
+ if (connection === undefined) {
166
+ return;
167
+ }
168
+
169
+ transport.send({
170
+ type: "host.heartbeat",
171
+ hostId: connection.hostId,
172
+ activeDesktopId: router.getSnapshot(connection.hostId).activeDesktopId,
173
+ });
174
+ },
175
+ };
176
+ }
177
+
178
+ export function createManagedHostRelayService(options) {
179
+ const heartbeatIntervalMs =
180
+ options.heartbeatIntervalMs ?? defaultHostHeartbeatIntervalMs;
181
+ const reconnectIntervalMs = options.reconnectIntervalMs ?? 3_000;
182
+ const router = createDesktopSessionRouter();
183
+ const cleanupHandlers = [];
184
+ let activeDesktopId;
185
+ let heartbeatTimer;
186
+ let reconnectTimer;
187
+ let relayConnected = false;
188
+ let relayConnectPromise;
189
+ let started = false;
190
+ let lastRelayError = null;
191
+
192
+ const publishRelayState = () => {
193
+ options.onRelayStateChange?.({
194
+ connected: relayConnected,
195
+ activeDesktopId,
196
+ errorMessage: lastRelayError,
197
+ at: Date.now(),
198
+ });
199
+ };
200
+
201
+ const sendHeartbeat = () => {
202
+ if (!relayConnected) {
203
+ return;
204
+ }
205
+
206
+ options.relayTransport.send({
207
+ type: "host.heartbeat",
208
+ hostId: options.config.hostId,
209
+ activeDesktopId,
210
+ });
211
+ };
212
+
213
+ const scheduleRelayReconnect = () => {
214
+ if (!started || relayConnected || reconnectTimer !== undefined) {
215
+ return;
216
+ }
217
+
218
+ reconnectTimer = globalThis.setTimeout(() => {
219
+ reconnectTimer = undefined;
220
+ void ensureRelayConnected();
221
+ }, reconnectIntervalMs);
222
+ reconnectTimer.unref?.();
223
+ };
224
+
225
+ const ensureRelayConnected = async () => {
226
+ if (!started || relayConnected) {
227
+ return;
228
+ }
229
+
230
+ if (relayConnectPromise !== undefined) {
231
+ return relayConnectPromise;
232
+ }
233
+
234
+ relayConnectPromise = (async () => {
235
+ try {
236
+ await options.relayTransport.connect();
237
+ relayConnected = true;
238
+ lastRelayError = null;
239
+ publishRelayState();
240
+ options.relayTransport.send({
241
+ type: "host.hello",
242
+ hostId: options.config.hostId,
243
+ });
244
+ } catch (error) {
245
+ relayConnected = false;
246
+ lastRelayError = normalizeErrorMessage(error);
247
+ publishRelayState();
248
+ scheduleRelayReconnect();
249
+ } finally {
250
+ relayConnectPromise = undefined;
251
+ }
252
+ })();
253
+
254
+ return relayConnectPromise;
255
+ };
256
+
257
+ const handleRelayClose = () => {
258
+ if (!started) {
259
+ return;
260
+ }
261
+
262
+ relayConnected = false;
263
+ activeDesktopId = undefined;
264
+ lastRelayError = "relay connection closed";
265
+ publishRelayState();
266
+ scheduleRelayReconnect();
267
+ };
268
+
269
+ const handleRelayMessage = async (message) => {
270
+ if (message?.type === "desktop-session.current" && message.hostId === options.config.hostId) {
271
+ activeDesktopId = message.desktopId;
272
+ router.activateDesktopSession({
273
+ hostId: message.hostId,
274
+ desktopId: message.desktopId,
275
+ });
276
+ publishRelayState();
277
+ return;
278
+ }
279
+
280
+ if (message?.type !== "desktop-request" || message.hostId !== options.config.hostId) {
281
+ return;
282
+ }
283
+
284
+ const decision = router.routeDesktopRequest({
285
+ hostId: message.hostId,
286
+ desktopId: message.desktopId,
287
+ });
288
+
289
+ if (!decision.accepted) {
290
+ return;
291
+ }
292
+
293
+ try {
294
+ const payload = await options.gatewayClient.call(
295
+ message.request.method,
296
+ message.request.params,
297
+ );
298
+ options.relayTransport.send({
299
+ type: "desktop-response",
300
+ hostId: options.config.hostId,
301
+ desktopId: message.desktopId,
302
+ requestId: message.request.id,
303
+ ok: true,
304
+ payload,
305
+ });
306
+ } catch (error) {
307
+ options.relayTransport.send({
308
+ type: "desktop-response",
309
+ hostId: options.config.hostId,
310
+ desktopId: message.desktopId,
311
+ requestId: message.request.id,
312
+ ok: false,
313
+ error: {
314
+ message: normalizeErrorMessage(error),
315
+ },
316
+ });
317
+ }
318
+ };
319
+
320
+ return {
321
+ async start() {
322
+ if (started) {
323
+ return;
324
+ }
325
+
326
+ started = true;
327
+ publishRelayState();
328
+ await options.gatewayClient.connect();
329
+ options.relayTransport.onMessage(handleRelayMessage);
330
+ options.relayTransport.onClose?.(handleRelayClose);
331
+ await ensureRelayConnected();
332
+
333
+ for (const eventName of relayEvents) {
334
+ cleanupHandlers.push(options.gatewayClient.subscribe(eventName, (payload) => {
335
+ if (!activeDesktopId || !relayConnected) {
336
+ return;
337
+ }
338
+
339
+ options.relayTransport.send({
340
+ type: "desktop-event",
341
+ hostId: options.config.hostId,
342
+ desktopId: activeDesktopId,
343
+ event: eventName,
344
+ payload,
345
+ });
346
+ }));
347
+ }
348
+
349
+ heartbeatTimer = globalThis.setInterval(sendHeartbeat, heartbeatIntervalMs);
350
+ },
351
+ async stop() {
352
+ if (!started) {
353
+ return;
354
+ }
355
+
356
+ started = false;
357
+
358
+ if (heartbeatTimer !== undefined) {
359
+ globalThis.clearInterval(heartbeatTimer);
360
+ heartbeatTimer = undefined;
361
+ }
362
+
363
+ if (reconnectTimer !== undefined) {
364
+ globalThis.clearTimeout(reconnectTimer);
365
+ reconnectTimer = undefined;
366
+ }
367
+
368
+ while (cleanupHandlers.length > 0) {
369
+ cleanupHandlers.pop()?.();
370
+ }
371
+
372
+ relayConnected = false;
373
+ lastRelayError = null;
374
+ await options.relayTransport.close();
375
+ await options.gatewayClient.close();
376
+ publishRelayState();
377
+ },
378
+ };
379
+ }
380
+
381
+ export function createWebSocketRelayTransport({ websocketUrl, authorization, hostId }) {
382
+ let socket;
383
+ let messageHandler = () => {};
384
+ let closeHandler = () => {};
385
+ let closing = false;
386
+
387
+ return {
388
+ async connect() {
389
+ const WebSocketCtor = await loadNodeWebSocket();
390
+
391
+ closing = false;
392
+ socket = new WebSocketCtor(websocketUrl, {
393
+ headers: {
394
+ authorization: `Bearer ${authorization}`,
395
+ "x-pinclaw-host-id": hostId,
396
+ },
397
+ });
398
+
399
+ await new Promise((resolvePromise, rejectPromise) => {
400
+ socket.once("open", resolvePromise);
401
+ socket.once("error", rejectPromise);
402
+ });
403
+
404
+ socket.on("message", (payload) => {
405
+ try {
406
+ messageHandler(JSON.parse(payload.toString("utf8")));
407
+ } catch {
408
+ // Ignore malformed relay frames.
409
+ }
410
+ });
411
+ socket.on("close", () => {
412
+ const wasClosing = closing;
413
+ socket = undefined;
414
+
415
+ if (!wasClosing) {
416
+ closeHandler();
417
+ }
418
+ });
419
+ },
420
+ send(frame) {
421
+ if (socket === undefined || socket.readyState !== 1) {
422
+ return;
423
+ }
424
+
425
+ socket.send(JSON.stringify(frame));
426
+ },
427
+ onMessage(handler) {
428
+ messageHandler = handler;
429
+ },
430
+ onClose(handler) {
431
+ closeHandler = handler;
432
+ },
433
+ async close() {
434
+ if (socket === undefined || socket.readyState === 3) {
435
+ return;
436
+ }
437
+
438
+ closing = true;
439
+ await new Promise((resolvePromise) => {
440
+ socket.once("close", resolvePromise);
441
+ socket.close(1000, "disconnect");
442
+ });
443
+ socket = undefined;
444
+ },
445
+ };
446
+ }
447
+
448
+ export function createOpenClawRuntimeBridge({ runtime, logger }) {
449
+ const emitter = createRuntimeEventEmitter();
450
+ const activeRuns = new Map();
451
+ const agentRunToClientRun = new Map();
452
+ const lastRunBySession = new Map();
453
+ const cleanupHandlers = [];
454
+ let connected = false;
455
+
456
+ const cleanupRun = (clientRunId) => {
457
+ const runState = activeRuns.get(clientRunId);
458
+
459
+ if (runState === undefined || runState.cleaned) {
460
+ return;
461
+ }
462
+
463
+ runState.cleaned = true;
464
+
465
+ if (runState.agentRunId !== undefined) {
466
+ agentRunToClientRun.delete(runState.agentRunId);
467
+ }
468
+
469
+ activeRuns.delete(clientRunId);
470
+ };
471
+
472
+ const rememberSessionAlias = (sessionKey, clientRunId) => {
473
+ if (typeof sessionKey !== "string" || sessionKey.trim().length === 0) {
474
+ return;
475
+ }
476
+
477
+ lastRunBySession.set(sessionKey, clientRunId);
478
+ };
479
+
480
+ const findRunIdBySession = (sessionKey) => {
481
+ if (typeof sessionKey !== "string" || sessionKey.trim().length === 0) {
482
+ return undefined;
483
+ }
484
+
485
+ if (lastRunBySession.has(sessionKey)) {
486
+ return lastRunBySession.get(sessionKey);
487
+ }
488
+
489
+ for (const [candidateSessionKey, candidateRunId] of lastRunBySession.entries()) {
490
+ if (
491
+ candidateSessionKey === sessionKey
492
+ || candidateSessionKey.endsWith(`:${sessionKey}`)
493
+ || sessionKey.endsWith(`:${candidateSessionKey}`)
494
+ ) {
495
+ return candidateRunId;
496
+ }
497
+ }
498
+
499
+ return undefined;
500
+ };
501
+
502
+ const emitStatusEvents = () => {
503
+ emitter.emit("presence", {
504
+ connected,
505
+ mode: "relay-only",
506
+ });
507
+ emitter.emit("health", {
508
+ status: connected ? "ok" : "offline",
509
+ mode: "relay-only",
510
+ });
511
+ };
512
+
513
+ const emitChatDelta = (runState, text) => {
514
+ if (text.trim().length === 0) {
515
+ return;
516
+ }
517
+
518
+ emitter.emit("chat", {
519
+ runId: runState.clientRunId,
520
+ sessionKey: runState.sessionKey,
521
+ state: "delta",
522
+ message: createChatMessage("assistant", text),
523
+ });
524
+ };
525
+
526
+ const emitChatFinal = (runState) => {
527
+ if (runState.completed) {
528
+ return;
529
+ }
530
+
531
+ runState.completed = true;
532
+
533
+ emitter.emit("chat", {
534
+ runId: runState.clientRunId,
535
+ sessionKey: runState.sessionKey,
536
+ state: "final",
537
+ ...(runState.buffer.trim().length > 0
538
+ ? { message: createChatMessage("assistant", runState.buffer) }
539
+ : {}),
540
+ });
541
+ };
542
+
543
+ const emitChatError = (runState, error) => {
544
+ if (runState.completed) {
545
+ return;
546
+ }
547
+
548
+ runState.completed = true;
549
+ emitter.emit("chat", {
550
+ runId: runState.clientRunId,
551
+ sessionKey: runState.sessionKey,
552
+ state: "error",
553
+ errorMessage: normalizeErrorMessage(error),
554
+ });
555
+ };
556
+
557
+ const handleTranscriptUpdate = (update) => {
558
+ const sessionKey =
559
+ typeof update?.sessionKey === "string" ? update.sessionKey.trim() : "";
560
+
561
+ if (sessionKey.length === 0) {
562
+ return;
563
+ }
564
+
565
+ const clientRunId = findRunIdBySession(sessionKey);
566
+ const runState = clientRunId === undefined ? undefined : activeRuns.get(clientRunId);
567
+
568
+ if (
569
+ runState === undefined
570
+ || runState.completed
571
+ || runState.aborted
572
+ || runState.sawAssistantEvent
573
+ ) {
574
+ return;
575
+ }
576
+
577
+ const message = normalizeTranscriptUpdateMessage(update.message);
578
+
579
+ if (message?.role !== "assistant") {
580
+ return;
581
+ }
582
+
583
+ const text = extractMessageText(message.content);
584
+
585
+ if (text.trim().length === 0) {
586
+ return;
587
+ }
588
+
589
+ runState.sessionKey = sessionKey;
590
+ runState.buffer = text;
591
+ rememberSessionAlias(sessionKey, runState.clientRunId);
592
+ emitChatFinal(runState);
593
+ cleanupRun(runState.clientRunId);
594
+ };
595
+
596
+ const handleAgentEvent = (event) => {
597
+ const clientRunId = agentRunToClientRun.get(event.runId) ?? event.runId;
598
+ const runState = activeRuns.get(clientRunId);
599
+ const sessionKey = resolveEventSessionKey(runState, event);
600
+ const desktopEvent = sessionKey === undefined
601
+ ? {
602
+ ...event,
603
+ runId: clientRunId,
604
+ }
605
+ : {
606
+ ...event,
607
+ runId: clientRunId,
608
+ sessionKey,
609
+ };
610
+
611
+ emitter.emit("agent", desktopEvent);
612
+
613
+ if (runState === undefined || sessionKey === undefined) {
614
+ return;
615
+ }
616
+
617
+ runState.sessionKey = sessionKey;
618
+ rememberSessionAlias(sessionKey, clientRunId);
619
+
620
+ if (runState.aborted) {
621
+ if (isLifecycleTerminal(event)) {
622
+ cleanupRun(clientRunId);
623
+ }
624
+ return;
625
+ }
626
+
627
+ if (event.stream === "assistant") {
628
+ const nextText = readAgentTextField(event.data, "text");
629
+ const nextDelta = readAgentTextField(event.data, "delta");
630
+ const mergedText = resolveMergedAssistantText(
631
+ runState.buffer,
632
+ nextText,
633
+ nextDelta,
634
+ );
635
+
636
+ if (mergedText !== runState.buffer) {
637
+ runState.buffer = mergedText;
638
+ runState.sawAssistantEvent = true;
639
+ emitChatDelta(runState, mergedText);
640
+ }
641
+
642
+ return;
643
+ }
644
+
645
+ if (event.stream !== "lifecycle") {
646
+ return;
647
+ }
648
+
649
+ const phase = readAgentTextField(event.data, "phase");
650
+
651
+ if (phase === "end") {
652
+ emitChatFinal(runState);
653
+ cleanupRun(clientRunId);
654
+ return;
655
+ }
656
+
657
+ if (phase === "error") {
658
+ emitChatError(
659
+ runState,
660
+ readAgentTextField(event.data, "error") || "OpenClaw runtime error",
661
+ );
662
+ cleanupRun(clientRunId);
663
+ }
664
+ };
665
+
666
+ const listSessions = async (params = {}) => {
667
+ const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : undefined;
668
+ const storeContext = await loadRuntimeStoreContext(runtime, sessionKey);
669
+ const sessions = Object.entries(storeContext.store)
670
+ .sort((left, right) => (right[1]?.updatedAt ?? 0) - (left[1]?.updatedAt ?? 0))
671
+ .map(([key, entry]) => ({
672
+ key,
673
+ sessionKey: key,
674
+ sessionId: entry.sessionId,
675
+ updatedAt: entry.updatedAt,
676
+ label: resolveSessionLabel(entry, key),
677
+ displayName: resolveSessionLabel(entry, key),
678
+ }));
679
+
680
+ return {
681
+ sessions,
682
+ ts: Date.now(),
683
+ };
684
+ };
685
+
686
+ const readHistory = async (params = {}) => {
687
+ const sessionKey = normalizeSessionKey(params.sessionKey);
688
+ const storeContext = await loadRuntimeStoreContext(runtime, sessionKey);
689
+ const resolvedEntry = resolveStoreEntry(storeContext.store, sessionKey, storeContext.agentId);
690
+
691
+ if (resolvedEntry === undefined) {
692
+ return {
693
+ sessionKey,
694
+ sessionId: sessionKey,
695
+ messages: [],
696
+ };
697
+ }
698
+
699
+ const sessionFile = runtime.agent.session.resolveSessionFilePath(
700
+ resolvedEntry.entry.sessionId,
701
+ resolvedEntry.entry,
702
+ {
703
+ agentId: storeContext.agentId,
704
+ },
705
+ );
706
+ const messages = readTranscriptMessages(sessionFile);
707
+
708
+ return {
709
+ sessionKey: resolvedEntry.key,
710
+ sessionId: resolvedEntry.entry.sessionId,
711
+ messages,
712
+ };
713
+ };
714
+
715
+ const sendChat = async (params = {}) => {
716
+ const sessionKey = normalizeSessionKey(params.sessionKey);
717
+ const message = normalizeChatMessage(params.message);
718
+ const clientRunId = normalizeRunId(params.idempotencyKey);
719
+ const { createReplyDispatcher, dispatchInboundMessage } = await loadReplyRuntime();
720
+ const cfg = await runtime.config.loadConfig();
721
+ const abortController = new globalThis.AbortController();
722
+ const runState = {
723
+ clientRunId,
724
+ sessionKey,
725
+ agentRunId: undefined,
726
+ abortController,
727
+ buffer: "",
728
+ sawAssistantEvent: false,
729
+ completed: false,
730
+ aborted: false,
731
+ cleaned: false,
732
+ };
733
+
734
+ activeRuns.set(clientRunId, runState);
735
+ rememberSessionAlias(sessionKey, clientRunId);
736
+
737
+ const dispatcher = createReplyDispatcher({
738
+ deliver: async (payload) => {
739
+ const nextText = typeof payload?.text === "string" ? payload.text : "";
740
+
741
+ if (nextText.trim().length === 0) {
742
+ return;
743
+ }
744
+
745
+ runState.buffer = resolveMergedAssistantText(
746
+ runState.buffer,
747
+ nextText,
748
+ nextText,
749
+ );
750
+ },
751
+ onError: (error) => {
752
+ logger.warn(`PinClaw relay reply dispatch failed: ${normalizeErrorMessage(error)}`);
753
+ },
754
+ });
755
+
756
+ void dispatchInboundMessage({
757
+ ctx: createInboundContext({
758
+ sessionKey,
759
+ message,
760
+ clientRunId,
761
+ }),
762
+ cfg,
763
+ dispatcher,
764
+ replyOptions: {
765
+ runId: clientRunId,
766
+ abortSignal: abortController.signal,
767
+ onAgentRunStart: (agentRunId) => {
768
+ runState.agentRunId = agentRunId;
769
+ agentRunToClientRun.set(agentRunId, clientRunId);
770
+ },
771
+ },
772
+ }).then(() => {
773
+ if (runState.aborted) {
774
+ cleanupRun(clientRunId);
775
+ return;
776
+ }
777
+
778
+ if (!runState.completed) {
779
+ emitChatFinal(runState);
780
+ }
781
+
782
+ cleanupRun(clientRunId);
783
+ }).catch((error) => {
784
+ if (runState.aborted) {
785
+ cleanupRun(clientRunId);
786
+ return;
787
+ }
788
+
789
+ emitChatError(runState, error);
790
+ cleanupRun(clientRunId);
791
+ });
792
+
793
+ return {
794
+ runId: clientRunId,
795
+ status: "started",
796
+ };
797
+ };
798
+
799
+ const abortChat = async (params = {}) => {
800
+ const requestedSessionKey =
801
+ typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
802
+ const requestedRunId = typeof params.runId === "string" ? params.runId.trim() : "";
803
+ const clientRunId = requestedRunId || findRunIdBySession(requestedSessionKey);
804
+ const runState = clientRunId === undefined ? undefined : activeRuns.get(clientRunId);
805
+
806
+ if (runState === undefined) {
807
+ return {
808
+ ok: true,
809
+ aborted: false,
810
+ runIds: [],
811
+ };
812
+ }
813
+
814
+ runState.aborted = true;
815
+ runState.abortController.abort();
816
+ emitter.emit("chat", {
817
+ runId: runState.clientRunId,
818
+ sessionKey: runState.sessionKey,
819
+ state: "aborted",
820
+ });
821
+
822
+ if (runState.agentRunId === undefined) {
823
+ cleanupRun(runState.clientRunId);
824
+ }
825
+
826
+ return {
827
+ ok: true,
828
+ aborted: true,
829
+ runIds: [runState.clientRunId],
830
+ };
831
+ };
832
+
833
+ const getChannelsStatus = async () => ({
834
+ ok: true,
835
+ mode: "relay-only",
836
+ connected,
837
+ });
838
+
839
+ return {
840
+ async connect() {
841
+ if (connected) {
842
+ return;
843
+ }
844
+
845
+ connected = true;
846
+ cleanupHandlers.push(runtime.events.onAgentEvent(handleAgentEvent));
847
+ cleanupHandlers.push(runtime.events.onSessionTranscriptUpdate(handleTranscriptUpdate));
848
+ emitStatusEvents();
849
+ },
850
+ async close() {
851
+ connected = false;
852
+
853
+ while (cleanupHandlers.length > 0) {
854
+ cleanupHandlers.pop()?.();
855
+ }
856
+
857
+ for (const runState of activeRuns.values()) {
858
+ runState.aborted = true;
859
+ runState.abortController.abort();
860
+ }
861
+
862
+ activeRuns.clear();
863
+ agentRunToClientRun.clear();
864
+ emitStatusEvents();
865
+ },
866
+ async call(method, params) {
867
+ switch (method) {
868
+ case "sessions.list":
869
+ return listSessions(params);
870
+ case "chat.history":
871
+ return readHistory(params);
872
+ case "chat.send":
873
+ return sendChat(params);
874
+ case "chat.abort":
875
+ return abortChat(params);
876
+ case "channels.status":
877
+ return getChannelsStatus();
878
+ default:
879
+ throw new Error(`unsupported OpenClaw runtime method: ${String(method)}`);
880
+ }
881
+ },
882
+ subscribe(eventName, handler) {
883
+ return emitter.subscribe(eventName, handler);
884
+ },
885
+ };
886
+ }
887
+
888
+ export const pinclawRelayChannelPlugin = createPinClawRelayChannelPlugin();
889
+
890
+ export default defineChannelPluginEntry({
891
+ id: "pinclaw",
892
+ name: "PinClaw Relay Plugin",
893
+ description: "PinClaw relay channel plugin for OpenClaw",
894
+ plugin: pinclawRelayChannelPlugin,
895
+ });
896
+
897
+ function createPinClawRelayChannelPlugin() {
898
+ return {
899
+ id: "pinclaw",
900
+ meta: pinClawChannelMeta,
901
+ reload: {
902
+ configPrefixes: [`channels.${pinClawChannelId}`],
903
+ },
904
+ capabilities: {
905
+ chatTypes: ["direct"],
906
+ polls: false,
907
+ reactions: false,
908
+ edit: false,
909
+ unsend: false,
910
+ reply: true,
911
+ groupManagement: false,
912
+ threads: false,
913
+ media: false,
914
+ nativeCommands: false,
915
+ },
916
+ configSchema: pinClawChannelConfigSchema,
917
+ config: {
918
+ listAccountIds(cfg) {
919
+ return listPinClawAccountIds(cfg);
920
+ },
921
+ resolveAccount(cfg, accountId) {
922
+ return resolvePinClawAccount(cfg, accountId);
923
+ },
924
+ inspectAccount(cfg, accountId) {
925
+ const account = resolvePinClawAccount(cfg, accountId);
926
+
927
+ return buildPinClawAccountSnapshot(account);
928
+ },
929
+ defaultAccountId() {
930
+ return defaultPinClawAccountId;
931
+ },
932
+ setAccountEnabled({ cfg, accountId, enabled }) {
933
+ return updatePinClawChannelConfig(cfg, accountId, (current) => ({
934
+ ...current,
935
+ enabled,
936
+ }));
937
+ },
938
+ deleteAccount({ cfg, accountId }) {
939
+ return deletePinClawChannelAccount(cfg, accountId);
940
+ },
941
+ isEnabled(account) {
942
+ return account.enabled;
943
+ },
944
+ disabledReason(account) {
945
+ return account.enabled ? undefined : "PinClaw channel disabled in OpenClaw config.";
946
+ },
947
+ isConfigured(account) {
948
+ return account.configured;
949
+ },
950
+ unconfiguredReason(account) {
951
+ return account.configured
952
+ ? undefined
953
+ : "Waiting for @pinclaw/host-connect to write pinclaw-relay-host.json.";
954
+ },
955
+ describeAccount(account) {
956
+ return buildPinClawAccountSnapshot(account);
957
+ },
958
+ },
959
+ setup: {
960
+ resolveAccountId({ accountId }) {
961
+ return normalizePinClawAccountId(accountId);
962
+ },
963
+ applyAccountConfig({ cfg, accountId, input }) {
964
+ return updatePinClawChannelConfig(cfg, accountId, (current) => ({
965
+ ...current,
966
+ enabled: true,
967
+ ...(typeof input?.name === "string" && input.name.trim().length > 0
968
+ ? { name: input.name.trim() }
969
+ : {}),
970
+ }));
971
+ },
972
+ applyAccountName({ cfg, accountId, name }) {
973
+ return updatePinClawChannelConfig(cfg, accountId, (current) => ({
974
+ ...current,
975
+ ...(typeof name === "string" && name.trim().length > 0
976
+ ? { name: name.trim() }
977
+ : {}),
978
+ }));
979
+ },
980
+ validateInput() {
981
+ return null;
982
+ },
983
+ },
984
+ status: {
985
+ defaultRuntime: createPinClawRuntimeSnapshot(defaultPinClawAccountId),
986
+ buildAccountSnapshot({ account, runtime }) {
987
+ return buildPinClawAccountSnapshot(account, runtime);
988
+ },
989
+ collectStatusIssues(accounts) {
990
+ return accounts.flatMap((account) => {
991
+ if (!account.enabled) {
992
+ return [];
993
+ }
994
+
995
+ if (!account.configured) {
996
+ return [{
997
+ channel: pinClawChannelId,
998
+ accountId: account.accountId,
999
+ kind: "config",
1000
+ message: "Missing pinclaw-relay-host.json host runtime config.",
1001
+ fix: "Run npx @pinclaw/host-connect to write the relay credential and enable the channel.",
1002
+ }];
1003
+ }
1004
+
1005
+ if (!account.connected) {
1006
+ return [{
1007
+ channel: pinClawChannelId,
1008
+ accountId: account.accountId,
1009
+ kind: "runtime",
1010
+ message: "PinClaw relay tunnel is not connected.",
1011
+ fix: "Check the relay URL, credential secret, and outbound websocket connectivity.",
1012
+ }];
1013
+ }
1014
+
1015
+ return [];
1016
+ });
1017
+ },
1018
+ },
1019
+ gateway: {
1020
+ async startAccount(ctx) {
1021
+ await startPinClawRelayChannelAccount(ctx);
1022
+ },
1023
+ async stopAccount(ctx) {
1024
+ await stopPinClawRelayChannelAccount(ctx.accountId, {
1025
+ account: ctx.account,
1026
+ getStatus: ctx.getStatus,
1027
+ setStatus: ctx.setStatus,
1028
+ });
1029
+ },
1030
+ },
1031
+ };
1032
+ }
1033
+
1034
+ async function startPinClawRelayChannelAccount(ctx) {
1035
+ const account = ctx.account ?? resolvePinClawAccount(ctx.cfg, ctx.accountId);
1036
+ let currentAccount = account;
1037
+ const updateStatus = (patch = {}, nextAccount = currentAccount) => {
1038
+ currentAccount = nextAccount;
1039
+ const currentStatus = ctx.getStatus?.() ?? createPinClawRuntimeSnapshot(currentAccount.accountId);
1040
+ ctx.setStatus(buildPinClawAccountSnapshot(currentAccount, {
1041
+ ...currentStatus,
1042
+ ...patch,
1043
+ }));
1044
+ };
1045
+
1046
+ await stopPinClawRelayChannelAccount(account.accountId);
1047
+ updateStatus({
1048
+ running: false,
1049
+ connected: false,
1050
+ lastError: null,
1051
+ healthState: account.enabled
1052
+ ? account.configured
1053
+ ? "connecting"
1054
+ : "unconfigured"
1055
+ : "disabled",
1056
+ });
1057
+
1058
+ if (!account.enabled) {
1059
+ ctx.log?.info?.("PinClaw relay channel disabled by OpenClaw config.");
1060
+ return;
1061
+ }
1062
+
1063
+ if (account.runtimeConfig === undefined) {
1064
+ ctx.log?.info?.("PinClaw relay host config missing, skip realtime host tunnel.");
1065
+ return;
1066
+ }
1067
+
1068
+ const logger = createPluginLogger(ctx.log);
1069
+ const createManagedService = (nextAccount) => createManagedHostRelayService({
1070
+ config: nextAccount.runtimeConfig,
1071
+ relayTransport: createWebSocketRelayTransport({
1072
+ websocketUrl: nextAccount.runtimeConfig.websocketUrl,
1073
+ authorization: nextAccount.runtimeConfig.credentialSecret,
1074
+ hostId: nextAccount.runtimeConfig.hostId,
1075
+ }),
1076
+ gatewayClient: createOpenClawRuntimeBridge({
1077
+ runtime: ctx.runtime,
1078
+ logger,
1079
+ }),
1080
+ onRelayStateChange({ connected, errorMessage, at }) {
1081
+ const currentStatus = ctx.getStatus?.() ?? createPinClawRuntimeSnapshot(nextAccount.accountId);
1082
+
1083
+ updateStatus({
1084
+ ...currentStatus,
1085
+ running: true,
1086
+ connected,
1087
+ lastEventAt: at,
1088
+ lastConnectedAt: connected
1089
+ ? at
1090
+ : currentStatus.lastConnectedAt ?? null,
1091
+ lastDisconnect: connected
1092
+ ? null
1093
+ : {
1094
+ at,
1095
+ ...(typeof errorMessage === "string" && errorMessage.trim().length > 0
1096
+ ? { error: errorMessage }
1097
+ : {}),
1098
+ },
1099
+ lastError:
1100
+ typeof errorMessage === "string" && errorMessage.trim().length > 0
1101
+ ? errorMessage
1102
+ : null,
1103
+ healthState: connected ? "connected" : "reconnecting",
1104
+ }, nextAccount);
1105
+ },
1106
+ });
1107
+ const managedService = createManagedService(account);
1108
+ const accountHandle = createPinClawChannelAccountHandle({
1109
+ account,
1110
+ managedService,
1111
+ createManagedService,
1112
+ loadAccount: () => resolvePinClawAccount(ctx.cfg, ctx.accountId),
1113
+ updateStatus,
1114
+ abortSignal: ctx.abortSignal,
1115
+ });
1116
+
1117
+ activePinClawChannelAccounts.set(account.accountId, accountHandle);
1118
+
1119
+ try {
1120
+ updateStatus({
1121
+ running: true,
1122
+ connected: false,
1123
+ lastStartAt: Date.now(),
1124
+ healthState: "starting",
1125
+ });
1126
+ await managedService.start();
1127
+ ctx.log?.info?.(`PinClaw relay channel started: ${account.runtimeConfig.hostId}`);
1128
+
1129
+ if (ctx.abortSignal.aborted) {
1130
+ await accountHandle.stop();
1131
+ }
1132
+ } catch (error) {
1133
+ activePinClawChannelAccounts.delete(account.accountId);
1134
+ accountHandle.cleanupAbortListener();
1135
+ updateStatus({
1136
+ running: false,
1137
+ connected: false,
1138
+ healthState: "error",
1139
+ lastError: normalizeErrorMessage(error),
1140
+ lastDisconnect: {
1141
+ at: Date.now(),
1142
+ error: normalizeErrorMessage(error),
1143
+ },
1144
+ lastStopAt: Date.now(),
1145
+ });
1146
+ throw error;
1147
+ }
1148
+ }
1149
+
1150
+ export function createPinClawChannelAccountHandle({
1151
+ account,
1152
+ managedService,
1153
+ createManagedService,
1154
+ loadAccount,
1155
+ updateStatus,
1156
+ abortSignal,
1157
+ configPollIntervalMs = 2_000,
1158
+ }) {
1159
+ let stopped = false;
1160
+ let currentAccount = account;
1161
+ let currentManagedService = managedService;
1162
+ let runtimeConfigSignature = serializeHostRelayRuntimeConfig(account.runtimeConfig);
1163
+ let refreshPromise = Promise.resolve();
1164
+ const abortHandler = () => {
1165
+ void handle.stop();
1166
+ };
1167
+ const refreshManagedServiceForRuntimeConfigChange = async () => {
1168
+ if (
1169
+ stopped
1170
+ || typeof createManagedService !== "function"
1171
+ || typeof loadAccount !== "function"
1172
+ ) {
1173
+ return;
1174
+ }
1175
+
1176
+ const nextAccount = loadAccount();
1177
+ const nextRuntimeConfigSignature = serializeHostRelayRuntimeConfig(nextAccount.runtimeConfig);
1178
+
1179
+ if (nextRuntimeConfigSignature === runtimeConfigSignature) {
1180
+ return;
1181
+ }
1182
+
1183
+ runtimeConfigSignature = nextRuntimeConfigSignature;
1184
+ currentAccount = nextAccount;
1185
+
1186
+ await currentManagedService.stop();
1187
+
1188
+ if (!nextAccount.enabled || nextAccount.runtimeConfig === undefined) {
1189
+ updateStatus({
1190
+ running: false,
1191
+ connected: false,
1192
+ healthState: nextAccount.configured ? "offline" : "unconfigured",
1193
+ lastError: null,
1194
+ lastStopAt: Date.now(),
1195
+ }, nextAccount);
1196
+ currentManagedService = createNoopManagedService();
1197
+ return;
1198
+ }
1199
+
1200
+ currentManagedService = createManagedService(nextAccount);
1201
+ updateStatus({
1202
+ running: true,
1203
+ connected: false,
1204
+ healthState: "starting",
1205
+ lastError: null,
1206
+ lastDisconnect: null,
1207
+ lastStartAt: Date.now(),
1208
+ }, nextAccount);
1209
+ await currentManagedService.start();
1210
+ };
1211
+ const configPollTimer =
1212
+ typeof createManagedService === "function" && typeof loadAccount === "function"
1213
+ ? globalThis.setInterval(() => {
1214
+ refreshPromise = refreshPromise
1215
+ .then(() => refreshManagedServiceForRuntimeConfigChange())
1216
+ .catch((error) => {
1217
+ updateStatus({
1218
+ running: false,
1219
+ connected: false,
1220
+ healthState: "error",
1221
+ lastError: normalizeErrorMessage(error),
1222
+ lastDisconnect: {
1223
+ at: Date.now(),
1224
+ error: normalizeErrorMessage(error),
1225
+ },
1226
+ lastStopAt: Date.now(),
1227
+ }, currentAccount);
1228
+ });
1229
+ }, configPollIntervalMs)
1230
+ : undefined;
1231
+
1232
+ configPollTimer?.unref?.();
1233
+ const handle = {
1234
+ cleanupAbortListener() {
1235
+ abortSignal.removeEventListener("abort", abortHandler);
1236
+
1237
+ if (configPollTimer !== undefined) {
1238
+ globalThis.clearInterval(configPollTimer);
1239
+ }
1240
+ },
1241
+ async stop() {
1242
+ if (stopped) {
1243
+ return;
1244
+ }
1245
+
1246
+ stopped = true;
1247
+ activePinClawChannelAccounts.delete(account.accountId);
1248
+ handle.cleanupAbortListener();
1249
+ await refreshPromise;
1250
+ await currentManagedService.stop();
1251
+ updateStatus({
1252
+ running: false,
1253
+ connected: false,
1254
+ healthState: currentAccount.configured ? "offline" : "unconfigured",
1255
+ lastStopAt: Date.now(),
1256
+ }, currentAccount);
1257
+ },
1258
+ };
1259
+
1260
+ abortSignal.addEventListener("abort", abortHandler, {
1261
+ once: true,
1262
+ });
1263
+
1264
+ return handle;
1265
+ }
1266
+
1267
+ async function stopPinClawRelayChannelAccount(accountId, statusContext) {
1268
+ const normalizedAccountId = normalizePinClawAccountId(accountId);
1269
+ const accountHandle = activePinClawChannelAccounts.get(normalizedAccountId);
1270
+
1271
+ if (accountHandle !== undefined) {
1272
+ await accountHandle.stop();
1273
+ return;
1274
+ }
1275
+
1276
+ if (statusContext?.setStatus) {
1277
+ const account =
1278
+ statusContext.account
1279
+ ?? resolvePinClawAccount({}, normalizedAccountId);
1280
+ const currentStatus =
1281
+ statusContext.getStatus?.()
1282
+ ?? createPinClawRuntimeSnapshot(normalizedAccountId);
1283
+
1284
+ statusContext.setStatus(buildPinClawAccountSnapshot(account, {
1285
+ ...currentStatus,
1286
+ running: false,
1287
+ connected: false,
1288
+ healthState: account.configured ? "offline" : "unconfigured",
1289
+ lastStopAt: Date.now(),
1290
+ }));
1291
+ }
1292
+ }
1293
+
1294
+ function listPinClawAccountIds(cfg) {
1295
+ const configAccountIds = resolvePinClawConfigAccountIds(cfg);
1296
+
1297
+ if (configAccountIds.length > 0) {
1298
+ return configAccountIds;
1299
+ }
1300
+
1301
+ return loadHostRelayRuntimeConfig() === undefined ? [] : [defaultPinClawAccountId];
1302
+ }
1303
+
1304
+ function resolvePinClawAccount(cfg, accountId) {
1305
+ const normalizedAccountId = normalizePinClawAccountId(accountId);
1306
+ const configSection = resolvePinClawAccountSection(cfg, normalizedAccountId);
1307
+ const runtimeConfig = loadHostRelayRuntimeConfig();
1308
+ const accountName =
1309
+ typeof configSection?.name === "string" && configSection.name.trim().length > 0
1310
+ ? configSection.name.trim()
1311
+ : "PinClaw Host";
1312
+
1313
+ return {
1314
+ accountId: normalizedAccountId,
1315
+ name: accountName,
1316
+ enabled: configSection?.enabled !== false,
1317
+ configured: runtimeConfig !== undefined,
1318
+ runtimeConfig,
1319
+ };
1320
+ }
1321
+
1322
+ function buildPinClawAccountSnapshot(account, runtimeSnapshot = {}) {
1323
+ const snapshot = isRecord(runtimeSnapshot)
1324
+ ? runtimeSnapshot
1325
+ : createPinClawRuntimeSnapshot(account.accountId);
1326
+
1327
+ return {
1328
+ ...createPinClawRuntimeSnapshot(account.accountId),
1329
+ ...snapshot,
1330
+ accountId: account.accountId,
1331
+ name: account.name,
1332
+ enabled: account.enabled,
1333
+ configured: account.configured,
1334
+ linked: account.configured,
1335
+ baseUrl: account.runtimeConfig?.relayUrl,
1336
+ credentialSource: account.configured ? "pinclaw-relay-host.json" : "missing",
1337
+ mode: "relay-channel",
1338
+ };
1339
+ }
1340
+
1341
+ function createPinClawRuntimeSnapshot(accountId) {
1342
+ return {
1343
+ accountId: normalizePinClawAccountId(accountId),
1344
+ enabled: true,
1345
+ configured: false,
1346
+ linked: false,
1347
+ running: false,
1348
+ connected: false,
1349
+ healthState: "offline",
1350
+ lastConnectedAt: null,
1351
+ lastDisconnect: null,
1352
+ lastError: null,
1353
+ lastEventAt: null,
1354
+ lastStartAt: null,
1355
+ lastStopAt: null,
1356
+ mode: "relay-channel",
1357
+ };
1358
+ }
1359
+
1360
+ function normalizePinClawAccountId(accountId) {
1361
+ return typeof accountId === "string" && accountId.trim().length > 0
1362
+ ? accountId.trim()
1363
+ : defaultPinClawAccountId;
1364
+ }
1365
+
1366
+ function createNoopManagedService() {
1367
+ return {
1368
+ async start() {},
1369
+ async stop() {},
1370
+ };
1371
+ }
1372
+
1373
+ function serializeHostRelayRuntimeConfig(runtimeConfig) {
1374
+ if (!isRecord(runtimeConfig)) {
1375
+ return "";
1376
+ }
1377
+
1378
+ return JSON.stringify({
1379
+ relayUrl: runtimeConfig.relayUrl,
1380
+ websocketUrl: runtimeConfig.websocketUrl,
1381
+ hostId: runtimeConfig.hostId,
1382
+ credentialId: runtimeConfig.credentialId,
1383
+ credentialSecret: runtimeConfig.credentialSecret,
1384
+ });
1385
+ }
1386
+
1387
+ function resolvePinClawConfigAccountIds(cfg) {
1388
+ const channelSection = resolvePinClawChannelSection(cfg);
1389
+
1390
+ if (channelSection === undefined) {
1391
+ return [];
1392
+ }
1393
+
1394
+ if (!isRecord(channelSection.accounts)) {
1395
+ return [defaultPinClawAccountId];
1396
+ }
1397
+
1398
+ return Object.keys(channelSection.accounts)
1399
+ .map((accountId) => normalizePinClawAccountId(accountId))
1400
+ .filter((accountId, index, accountIds) => accountIds.indexOf(accountId) === index);
1401
+ }
1402
+
1403
+ function resolvePinClawChannelSection(cfg) {
1404
+ if (!isRecord(cfg) || !isRecord(cfg.channels)) {
1405
+ return undefined;
1406
+ }
1407
+
1408
+ const channelSection = cfg.channels[pinClawChannelId];
1409
+
1410
+ return isRecord(channelSection) ? channelSection : undefined;
1411
+ }
1412
+
1413
+ function resolvePinClawAccountSection(cfg, accountId) {
1414
+ const channelSection = resolvePinClawChannelSection(cfg);
1415
+
1416
+ if (channelSection === undefined) {
1417
+ return undefined;
1418
+ }
1419
+
1420
+ if (!isRecord(channelSection.accounts)) {
1421
+ return channelSection;
1422
+ }
1423
+
1424
+ const accountSection = channelSection.accounts[normalizePinClawAccountId(accountId)];
1425
+
1426
+ return isRecord(accountSection) ? accountSection : undefined;
1427
+ }
1428
+
1429
+ function updatePinClawChannelConfig(cfg, accountId, mutateAccount) {
1430
+ const normalizedAccountId = normalizePinClawAccountId(accountId);
1431
+ const nextConfig = isRecord(cfg) ? { ...cfg } : {};
1432
+ const nextChannels = isRecord(nextConfig.channels) ? { ...nextConfig.channels } : {};
1433
+ const currentChannelSection = resolvePinClawChannelSection(nextConfig) ?? {};
1434
+
1435
+ if (normalizedAccountId === defaultPinClawAccountId && !isRecord(currentChannelSection.accounts)) {
1436
+ nextChannels[pinClawChannelId] = mutateAccount({
1437
+ ...currentChannelSection,
1438
+ });
1439
+ nextConfig.channels = nextChannels;
1440
+ return nextConfig;
1441
+ }
1442
+
1443
+ const nextChannelSection = {
1444
+ ...currentChannelSection,
1445
+ accounts: isRecord(currentChannelSection.accounts)
1446
+ ? { ...currentChannelSection.accounts }
1447
+ : {},
1448
+ };
1449
+ const currentAccountSection = isRecord(nextChannelSection.accounts[normalizedAccountId])
1450
+ ? {
1451
+ ...nextChannelSection.accounts[normalizedAccountId],
1452
+ }
1453
+ : {};
1454
+
1455
+ nextChannelSection.accounts[normalizedAccountId] = mutateAccount(currentAccountSection);
1456
+ nextChannels[pinClawChannelId] = nextChannelSection;
1457
+ nextConfig.channels = nextChannels;
1458
+
1459
+ return nextConfig;
1460
+ }
1461
+
1462
+ function deletePinClawChannelAccount(cfg, accountId) {
1463
+ const normalizedAccountId = normalizePinClawAccountId(accountId);
1464
+
1465
+ if (!isRecord(cfg)) {
1466
+ return {};
1467
+ }
1468
+
1469
+ if (!isRecord(cfg.channels)) {
1470
+ return {
1471
+ ...cfg,
1472
+ };
1473
+ }
1474
+
1475
+ const nextConfig = {
1476
+ ...cfg,
1477
+ channels: {
1478
+ ...cfg.channels,
1479
+ },
1480
+ };
1481
+ const channelSection = resolvePinClawChannelSection(cfg);
1482
+
1483
+ if (channelSection === undefined) {
1484
+ return nextConfig;
1485
+ }
1486
+
1487
+ if (normalizedAccountId === defaultPinClawAccountId && !isRecord(channelSection.accounts)) {
1488
+ delete nextConfig.channels[pinClawChannelId];
1489
+ return nextConfig;
1490
+ }
1491
+
1492
+ if (!isRecord(channelSection.accounts)) {
1493
+ delete nextConfig.channels[pinClawChannelId];
1494
+ return nextConfig;
1495
+ }
1496
+
1497
+ const nextAccounts = {
1498
+ ...channelSection.accounts,
1499
+ };
1500
+
1501
+ delete nextAccounts[normalizedAccountId];
1502
+
1503
+ nextConfig.channels[pinClawChannelId] = Object.keys(nextAccounts).length === 0
1504
+ ? {
1505
+ ...channelSection,
1506
+ accounts: {},
1507
+ }
1508
+ : {
1509
+ ...channelSection,
1510
+ accounts: nextAccounts,
1511
+ };
1512
+
1513
+ return nextConfig;
1514
+ }
1515
+
1516
+ function loadHostRelayRuntimeConfig(homeDirectory = homedir()) {
1517
+ const configPath = join(homeDirectory, ".openclaw", "pinclaw-relay-host.json");
1518
+
1519
+ if (!existsSync(configPath)) {
1520
+ return undefined;
1521
+ }
1522
+
1523
+ return JSON.parse(readFileSync(configPath, "utf8"));
1524
+ }
1525
+
1526
+ function createRuntimeEventEmitter() {
1527
+ const handlersByEvent = new Map(
1528
+ relayEvents.map((eventName) => [eventName, new Set()]),
1529
+ );
1530
+
1531
+ return {
1532
+ emit(eventName, payload) {
1533
+ handlersByEvent.get(eventName)?.forEach((handler) => {
1534
+ handler(payload);
1535
+ });
1536
+ },
1537
+ subscribe(eventName, handler) {
1538
+ const handlers = handlersByEvent.get(eventName);
1539
+
1540
+ if (handlers === undefined) {
1541
+ throw new Error(`unsupported runtime event: ${String(eventName)}`);
1542
+ }
1543
+
1544
+ handlers.add(handler);
1545
+
1546
+ return () => {
1547
+ handlers.delete(handler);
1548
+ };
1549
+ },
1550
+ };
1551
+ }
1552
+
1553
+ function resolveEventSessionKey(runState, event) {
1554
+ if (typeof event?.sessionKey === "string" && event.sessionKey.trim().length > 0) {
1555
+ return event.sessionKey;
1556
+ }
1557
+
1558
+ if (runState?.sessionKey) {
1559
+ return runState.sessionKey;
1560
+ }
1561
+
1562
+ return undefined;
1563
+ }
1564
+
1565
+ function isLifecycleTerminal(event) {
1566
+ if (event?.stream !== "lifecycle" || event.data === null || typeof event.data !== "object") {
1567
+ return false;
1568
+ }
1569
+
1570
+ const phase = event.data.phase;
1571
+
1572
+ return phase === "end" || phase === "error";
1573
+ }
1574
+
1575
+ function readAgentTextField(data, key) {
1576
+ if (data === null || typeof data !== "object") {
1577
+ return "";
1578
+ }
1579
+
1580
+ const value = data[key];
1581
+
1582
+ return typeof value === "string" ? value : "";
1583
+ }
1584
+
1585
+ async function loadRuntimeStoreContext(runtime, sessionKey) {
1586
+ const cfg = await runtime.config.loadConfig();
1587
+ const agentId = resolveAgentIdFromSessionKey(sessionKey);
1588
+ const storePath = runtime.agent.session.resolveStorePath(cfg.session?.store, {
1589
+ agentId,
1590
+ });
1591
+ const store = runtime.agent.session.loadSessionStore(storePath);
1592
+
1593
+ return {
1594
+ cfg,
1595
+ agentId,
1596
+ storePath,
1597
+ store,
1598
+ };
1599
+ }
1600
+
1601
+ function resolveAgentIdFromSessionKey(sessionKey) {
1602
+ if (typeof sessionKey !== "string") {
1603
+ return "main";
1604
+ }
1605
+
1606
+ const normalized = sessionKey.trim();
1607
+
1608
+ if (!normalized.startsWith("agent:")) {
1609
+ return "main";
1610
+ }
1611
+
1612
+ const parts = normalized.split(":");
1613
+
1614
+ return parts[1] && parts[1].trim().length > 0 ? parts[1] : "main";
1615
+ }
1616
+
1617
+ function resolveStoreEntry(store, sessionKey, agentId) {
1618
+ const candidates = new Set([sessionKey]);
1619
+
1620
+ if (!sessionKey.startsWith("agent:")) {
1621
+ candidates.add(`agent:${agentId}:${sessionKey}`);
1622
+ }
1623
+
1624
+ for (const candidate of candidates) {
1625
+ if (store[candidate] !== undefined) {
1626
+ return {
1627
+ key: candidate,
1628
+ entry: store[candidate],
1629
+ };
1630
+ }
1631
+ }
1632
+
1633
+ for (const [key, entry] of Object.entries(store)) {
1634
+ if (key === sessionKey || key.endsWith(`:${sessionKey}`)) {
1635
+ return {
1636
+ key,
1637
+ entry,
1638
+ };
1639
+ }
1640
+ }
1641
+
1642
+ return undefined;
1643
+ }
1644
+
1645
+ function readTranscriptMessages(sessionFilePath) {
1646
+ if (!existsSync(sessionFilePath)) {
1647
+ return [];
1648
+ }
1649
+
1650
+ return readFileSync(sessionFilePath, "utf8")
1651
+ .split("\n")
1652
+ .map((line) => line.trim())
1653
+ .filter(Boolean)
1654
+ .map((line) => {
1655
+ try {
1656
+ return JSON.parse(line);
1657
+ } catch {
1658
+ return null;
1659
+ }
1660
+ })
1661
+ .flatMap((record, index) => {
1662
+ const message = normalizeTranscriptRecord(record, index);
1663
+
1664
+ return message === null ? [] : [message];
1665
+ });
1666
+ }
1667
+
1668
+ function normalizeTranscriptRecord(record, index) {
1669
+ if (record === null || typeof record !== "object") {
1670
+ return null;
1671
+ }
1672
+
1673
+ const message =
1674
+ record.type === "message" && record.message !== null && typeof record.message === "object"
1675
+ ? record.message
1676
+ : record;
1677
+ const role = typeof message.role === "string" ? message.role : "";
1678
+
1679
+ if (role !== "user" && role !== "assistant" && role !== "system") {
1680
+ return null;
1681
+ }
1682
+
1683
+ return {
1684
+ id: resolveTranscriptMessageId(message, index),
1685
+ role,
1686
+ content: createTextContent(extractMessageText(message.content)),
1687
+ timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
1688
+ };
1689
+ }
1690
+
1691
+ function normalizeTranscriptUpdateMessage(message) {
1692
+ if (message === null || typeof message !== "object") {
1693
+ return null;
1694
+ }
1695
+
1696
+ const role = typeof message.role === "string" ? message.role : "";
1697
+
1698
+ if (role !== "assistant" && role !== "user" && role !== "system") {
1699
+ return null;
1700
+ }
1701
+
1702
+ return {
1703
+ role,
1704
+ content: message.content,
1705
+ timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
1706
+ };
1707
+ }
1708
+
1709
+ function resolveTranscriptMessageId(message, index) {
1710
+ if (typeof message.id === "string" && message.id.trim().length > 0) {
1711
+ return message.id;
1712
+ }
1713
+
1714
+ if (typeof message.messageId === "string" && message.messageId.trim().length > 0) {
1715
+ return message.messageId;
1716
+ }
1717
+
1718
+ return `transcript-${index}`;
1719
+ }
1720
+
1721
+ function extractMessageText(content) {
1722
+ if (typeof content === "string") {
1723
+ return content;
1724
+ }
1725
+
1726
+ if (!Array.isArray(content)) {
1727
+ return "";
1728
+ }
1729
+
1730
+ return content
1731
+ .flatMap((item) => {
1732
+ if (item === null || typeof item !== "object") {
1733
+ return [];
1734
+ }
1735
+
1736
+ if (item.type !== "text" || typeof item.text !== "string") {
1737
+ return [];
1738
+ }
1739
+
1740
+ return [item.text];
1741
+ })
1742
+ .join("\n");
1743
+ }
1744
+
1745
+ function resolveMergedAssistantText(previousText, nextText, nextDelta) {
1746
+ const normalizedText = typeof nextText === "string" ? nextText : "";
1747
+ const normalizedDelta = typeof nextDelta === "string" ? nextDelta : "";
1748
+
1749
+ if (normalizedText.length > 0) {
1750
+ if (normalizedText.startsWith(previousText)) {
1751
+ return normalizedText;
1752
+ }
1753
+
1754
+ if (
1755
+ normalizedDelta.length > 0
1756
+ && normalizedText === `${previousText}${normalizedDelta}`
1757
+ ) {
1758
+ return normalizedText;
1759
+ }
1760
+
1761
+ if (normalizedDelta.length > 0) {
1762
+ return `${previousText}${normalizedDelta}`;
1763
+ }
1764
+
1765
+ return normalizedText;
1766
+ }
1767
+
1768
+ if (normalizedDelta.length > 0) {
1769
+ return `${previousText}${normalizedDelta}`;
1770
+ }
1771
+
1772
+ return previousText;
1773
+ }
1774
+
1775
+ function createChatMessage(role, text) {
1776
+ return {
1777
+ role,
1778
+ content: createTextContent(text),
1779
+ timestamp: Date.now(),
1780
+ };
1781
+ }
1782
+
1783
+ function createTextContent(text) {
1784
+ return [
1785
+ {
1786
+ type: "text",
1787
+ text,
1788
+ },
1789
+ ];
1790
+ }
1791
+
1792
+ function createInboundContext({ sessionKey, message, clientRunId }) {
1793
+ const timestamp = Date.now();
1794
+
1795
+ return {
1796
+ Body: message,
1797
+ BodyForAgent: message,
1798
+ BodyForCommands: message,
1799
+ RawBody: message,
1800
+ CommandBody: message,
1801
+ SessionKey: sessionKey,
1802
+ Provider: "webchat",
1803
+ Surface: "webchat",
1804
+ ChatType: "direct",
1805
+ CommandAuthorized: true,
1806
+ MessageSid: clientRunId,
1807
+ Timestamp: timestamp,
1808
+ };
1809
+ }
1810
+
1811
+ function resolveSessionLabel(entry, fallbackKey) {
1812
+ for (const candidate of [entry?.displayName, entry?.label, entry?.subject, fallbackKey]) {
1813
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
1814
+ return candidate;
1815
+ }
1816
+ }
1817
+
1818
+ return fallbackKey;
1819
+ }
1820
+
1821
+ function normalizeSessionKey(sessionKey) {
1822
+ if (typeof sessionKey !== "string" || sessionKey.trim().length === 0) {
1823
+ throw new Error("chat.send 需要 sessionKey。");
1824
+ }
1825
+
1826
+ return sessionKey.trim();
1827
+ }
1828
+
1829
+ function normalizeChatMessage(message) {
1830
+ if (typeof message !== "string" || message.trim().length === 0) {
1831
+ throw new Error("chat.send 需要 message。");
1832
+ }
1833
+
1834
+ return message.trim();
1835
+ }
1836
+
1837
+ function normalizeRunId(idempotencyKey) {
1838
+ return typeof idempotencyKey === "string" && idempotencyKey.trim().length > 0
1839
+ ? idempotencyKey.trim()
1840
+ : randomUUID();
1841
+ }
1842
+
1843
+ function normalizeErrorMessage(error) {
1844
+ return error instanceof Error ? error.message : String(error);
1845
+ }
1846
+
1847
+ function resolvePluginConfigSchema(configSchema = emptyPluginConfigSchema) {
1848
+ return typeof configSchema === "function" ? configSchema() : configSchema;
1849
+ }
1850
+
1851
+ function defineChannelPluginEntry({
1852
+ id,
1853
+ name,
1854
+ description,
1855
+ plugin,
1856
+ configSchema,
1857
+ setRuntime,
1858
+ registerFull,
1859
+ }) {
1860
+ return {
1861
+ id,
1862
+ name,
1863
+ description,
1864
+ configSchema: resolvePluginConfigSchema(configSchema),
1865
+ register(api) {
1866
+ setRuntime?.(api.runtime);
1867
+ api.registerChannel({ plugin });
1868
+
1869
+ if (api.registrationMode === "setup-only") {
1870
+ return;
1871
+ }
1872
+
1873
+ registerFull?.(api);
1874
+ },
1875
+ };
1876
+ }
1877
+
1878
+ function createPluginLogger(logSink = {}) {
1879
+ return {
1880
+ info(message) {
1881
+ logSink.info?.(message);
1882
+ },
1883
+ warn(message) {
1884
+ logSink.warn?.(message);
1885
+ },
1886
+ error(message) {
1887
+ logSink.error?.(message);
1888
+ },
1889
+ debug(message) {
1890
+ logSink.debug?.(message);
1891
+ },
1892
+ };
1893
+ }
1894
+
1895
+ function isRecord(value) {
1896
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1897
+ }
1898
+
1899
+ async function loadReplyRuntime() {
1900
+ if (replyRuntimePromise === undefined) {
1901
+ replyRuntimePromise = importOpenClawModule("openclaw/plugin-sdk/reply-runtime");
1902
+ }
1903
+
1904
+ return replyRuntimePromise;
1905
+ }
1906
+
1907
+ async function importOpenClawModule(specifier) {
1908
+ try {
1909
+ return await import(specifier);
1910
+ } catch (error) {
1911
+ const modulePath = resolveOpenClawModulePath(specifier);
1912
+
1913
+ if (modulePath === undefined) {
1914
+ throw error;
1915
+ }
1916
+
1917
+ return import(modulePath);
1918
+ }
1919
+ }
1920
+
1921
+ function resolveOpenClawModulePath(specifier) {
1922
+ try {
1923
+ return require.resolve(specifier);
1924
+ } catch (error) {
1925
+ void error;
1926
+ }
1927
+
1928
+ for (const lookupPath of openClawLookupPaths) {
1929
+ try {
1930
+ return require.resolve(specifier, {
1931
+ paths: [lookupPath],
1932
+ });
1933
+ } catch (error) {
1934
+ void error;
1935
+ }
1936
+ }
1937
+
1938
+ return undefined;
1939
+ }
1940
+
1941
+ async function loadNodeWebSocket() {
1942
+ const module = await import("ws");
1943
+
1944
+ return module.default;
1945
+ }