@chrysb/alphaclaw 0.8.3-beta.0 → 0.8.3-beta.2

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.
@@ -1,13 +1,13 @@
1
- import { h } from "https://esm.sh/preact";
1
+ import { h } from "preact";
2
2
  import {
3
3
  useCallback,
4
4
  useEffect,
5
5
  useMemo,
6
6
  useRef,
7
7
  useState,
8
- } from "https://esm.sh/preact/hooks";
9
- import htm from "https://esm.sh/htm";
10
- import { marked } from "https://esm.sh/marked";
8
+ } from "preact/hooks";
9
+ import htm from "htm";
10
+ import { marked } from "marked";
11
11
  import { authFetch } from "../../lib/api.js";
12
12
  import { kChatSessionDraftsStorageKey } from "../../lib/storage-keys.js";
13
13
  import { showToast } from "../toast.js";
@@ -56,12 +56,13 @@ export const useBrowseNavigation = ({
56
56
  location,
57
57
  });
58
58
 
59
+ // Derive sidebar tab only from `location`. Avoid optimistic setSidebarTab + this effect
60
+ // fighting (e.g. chat tab selected while hash is still /general → pane never mounts).
59
61
  useEffect(() => {
60
- setSidebarTab((currentTab) => {
62
+ setSidebarTab(() => {
61
63
  if (location.startsWith("/browse")) return "browse";
62
64
  if (location.startsWith("/chat")) return "chat";
63
- if (currentTab === "browse" || currentTab === "chat") return "menu";
64
- return currentTab;
65
+ return "menu";
65
66
  });
66
67
  }, [location]);
67
68
 
@@ -148,7 +149,6 @@ export const useBrowseNavigation = ({
148
149
  }, [browsePreviewPath, onCloseMobileSidebar, selectedBrowsePath, setLocation]);
149
150
 
150
151
  const handleSelectSidebarTab = useCallback((nextTab) => {
151
- setSidebarTab(nextTab);
152
152
  if (nextTab === "menu" && location.startsWith("/browse")) {
153
153
  setBrowsePreviewPath("");
154
154
  setLocation(lastMenuRoute || `/${kDefaultUiTab}`);
@@ -7,7 +7,15 @@ const kConnectTimeoutMs = 8000;
7
7
  const kHistoryTimeoutMs = 12000;
8
8
  const kGatewayReqTimeoutMs = 15000;
9
9
  const kGatewayProtocolVersion = 3;
10
- const kGatewayAdminScopes = ["operator.admin"];
10
+ // Gateway method auth (see OpenClaw method-scopes): chat.history needs operator.read;
11
+ // chat.send / chat.abort need operator.write. Align with CLI_DEFAULT_OPERATOR_SCOPES plus admin.
12
+ const kGatewayChatBridgeScopes = [
13
+ "operator.admin",
14
+ "operator.read",
15
+ "operator.write",
16
+ "operator.approvals",
17
+ "operator.pairing",
18
+ ];
11
19
 
12
20
  const collectHistoryTextFragments = (value) => {
13
21
  if (typeof value === "string") {
@@ -252,9 +260,35 @@ const resolveSessionKeyFromPayload = (payload = {}) =>
252
260
 
253
261
  const sanitizeError = (error) => {
254
262
  const message = error instanceof Error ? error.message : String(error || "");
255
- if (message.toLowerCase().includes("not connected")) {
263
+ const lower = message.toLowerCase();
264
+ console.error(`[alphaclaw] chat websocket handler error: ${message}`);
265
+ if (lower.includes("not connected")) {
256
266
  return "Agent runtime is not connected right now.";
257
267
  }
268
+ if (
269
+ lower.includes("gateway is not connected") ||
270
+ lower.includes("econnrefused") ||
271
+ lower.includes("connect failed")
272
+ ) {
273
+ return "Could not connect to the OpenClaw gateway. Check that the gateway is running and reachable.";
274
+ }
275
+ if (lower.includes("timed out") || lower.includes("timeout")) {
276
+ return "The gateway did not respond in time. Try again after the gateway finishes starting.";
277
+ }
278
+ if (
279
+ lower.includes("auth") ||
280
+ lower.includes("token") ||
281
+ lower.includes("unauthorized") ||
282
+ lower.includes("forbidden")
283
+ ) {
284
+ return "Gateway authentication failed. Verify OPENCLAW_GATEWAY_TOKEN matches the gateway.";
285
+ }
286
+ if (lower.includes("method not found") || lower.includes("unknown method")) {
287
+ return "This gateway build does not support chat APIs. Update OpenClaw.";
288
+ }
289
+ if (lower.includes("gateway request failed")) {
290
+ return "The gateway could not start this chat run. Check gateway logs.";
291
+ }
258
292
  return "Something went wrong. Please try again.";
259
293
  };
260
294
 
@@ -522,7 +556,7 @@ const createChatWsService = ({
522
556
  mode: "backend",
523
557
  },
524
558
  role: "operator",
525
- scopes: kGatewayAdminScopes,
559
+ scopes: kGatewayChatBridgeScopes,
526
560
  caps: [],
527
561
  commands: [],
528
562
  permissions: {},
@@ -733,9 +767,12 @@ const createChatWsService = ({
733
767
  }
734
768
  };
735
769
  run().catch((err) => {
770
+ const sessionKey = String(payload?.sessionKey || "").trim();
736
771
  sendJson(ws, {
737
772
  type: "error",
738
773
  message: sanitizeError(err),
774
+ ...(sessionKey ? { sessionKey } : {}),
775
+ messageId: crypto.randomUUID(),
739
776
  });
740
777
  });
741
778
  });
@@ -135,6 +135,8 @@ const kFallbackOnboardingModels = [
135
135
 
136
136
  const kVersionCacheTtlMs = 60 * 1000;
137
137
  const kLatestVersionCacheTtlMs = 10 * 60 * 1000;
138
+ /** `cp` of a full openclaw npm tree into /app/node_modules can exceed 60s on slow volumes. */
139
+ const kOpenclawUpdateCopyTimeoutMs = 5 * 60 * 1000;
138
140
  const kOpenclawRegistryUrl = "https://registry.npmjs.org/openclaw";
139
141
  const kAlphaclawRegistryUrl = "https://registry.npmjs.org/@chrysb%2falphaclaw";
140
142
  const kAlphaclawGithubReleasesBaseUrl =
@@ -370,6 +372,7 @@ const SETUP_API_PREFIXES = [
370
372
  "/api/codex",
371
373
  "/api/models",
372
374
  "/api/browse",
375
+ "/api/chat",
373
376
  "/api/gateway",
374
377
  "/api/restart-status",
375
378
  "/api/onboard",
@@ -425,6 +428,7 @@ module.exports = {
425
428
  kFallbackOnboardingModels,
426
429
  kVersionCacheTtlMs,
427
430
  kLatestVersionCacheTtlMs,
431
+ kOpenclawUpdateCopyTimeoutMs,
428
432
  kOpenclawRegistryUrl,
429
433
  kAlphaclawRegistryUrl,
430
434
  kAlphaclawGithubReleasesBaseUrl,
@@ -6,6 +6,7 @@ const {
6
6
  kVersionCacheTtlMs,
7
7
  kLatestVersionCacheTtlMs,
8
8
  kNpmPackageRoot,
9
+ kOpenclawUpdateCopyTimeoutMs,
9
10
  } = require("./constants");
10
11
  const { normalizeOpenclawVersion } = require("./helpers");
11
12
  const { parseJsonObjectFromNoisyOutput } = require("./utils/json");
@@ -174,7 +175,7 @@ const createOpenclawVersionService = ({
174
175
  const dest = path.join(installDir, "node_modules");
175
176
  exec(
176
177
  `cp -af "${src}/." "${dest}/"`,
177
- { timeout: 60000 },
178
+ { timeout: kOpenclawUpdateCopyTimeoutMs },
178
179
  (cpErr) => {
179
180
  cleanup();
180
181
  if (cpErr) {
@@ -29,8 +29,8 @@ const registerProxyRoutes = ({
29
29
  app.all(kHooksPathPattern, webhookMiddleware);
30
30
  app.all(kWebhookPathPattern, webhookMiddleware);
31
31
 
32
- app.all(kApiPathPattern, (req, res) => {
33
- if (SETUP_API_PREFIXES.some((p) => req.path.startsWith(p))) return;
32
+ app.all(kApiPathPattern, (req, res, next) => {
33
+ if (SETUP_API_PREFIXES.some((p) => req.path.startsWith(p))) return next();
34
34
  proxy.web(req, res, { target: getGatewayUrl() });
35
35
  });
36
36
  };
@@ -1,5 +1,15 @@
1
1
  const kSlackApiBase = "https://slack.com/api";
2
+ const fs = require("fs");
3
+ const { Readable } = require("stream");
4
+ const { Blob } = require("buffer");
2
5
 
6
+ /**
7
+ * Create Slack API client with enhanced features:
8
+ * - Threading support
9
+ * - Reactions
10
+ * - File uploads
11
+ * - Backward compatible with existing code
12
+ */
3
13
  const createSlackApi = (getToken) => {
4
14
  const call = async (method, body = {}) => {
5
15
  const token = typeof getToken === "function" ? getToken() : getToken;
@@ -24,14 +34,213 @@ const createSlackApi = (getToken) => {
24
34
  return data;
25
35
  };
26
36
 
37
+ /**
38
+ * Convert various file input types to Buffer
39
+ */
40
+ const toBuffer = async (content) => {
41
+ if (Buffer.isBuffer(content)) {
42
+ return content;
43
+ } else if (content instanceof Readable) {
44
+ const chunks = [];
45
+ for await (const chunk of content) {
46
+ chunks.push(chunk);
47
+ }
48
+ return Buffer.concat(chunks);
49
+ } else if (typeof content === "string" && fs.existsSync(content)) {
50
+ return fs.readFileSync(content);
51
+ } else {
52
+ throw new Error("Invalid file content: must be Buffer, Stream, or file path");
53
+ }
54
+ };
55
+
56
+ /**
57
+ * Verify Slack credentials
58
+ */
27
59
  const authTest = () => call("auth.test");
28
60
 
29
- const postMessage = (channel, text) =>
30
- call("chat.postMessage", { channel, text: String(text || "") });
61
+ /**
62
+ * Send a message to a channel or DM
63
+ * @param {string} channel - Channel ID or user ID
64
+ * @param {string} text - Message text
65
+ * @param {object} opts - Options
66
+ * @param {string} opts.thread_ts - Thread timestamp (for threaded replies)
67
+ * @param {boolean} opts.reply_broadcast - Also send to channel (when in thread)
68
+ * @param {boolean} opts.mrkdwn - Enable Slack markdown formatting (default: true)
69
+ * @returns {Promise<object>} Response with ts (message timestamp)
70
+ */
71
+ const postMessage = (channel, text, opts = {}) => {
72
+ const payload = {
73
+ channel,
74
+ text: String(text || ""),
75
+ };
76
+
77
+ // Threading support
78
+ if (opts.thread_ts) {
79
+ payload.thread_ts = opts.thread_ts;
80
+ }
81
+ if (opts.reply_broadcast) {
82
+ payload.reply_broadcast = true;
83
+ }
84
+
85
+ // Formatting
86
+ if (opts.mrkdwn !== false) {
87
+ payload.mrkdwn = true;
88
+ }
89
+
90
+ return call("chat.postMessage", payload);
91
+ };
92
+
93
+ /**
94
+ * Post a message in a thread (convenience wrapper)
95
+ * @param {string} channel - Channel ID
96
+ * @param {string} threadTs - Thread timestamp
97
+ * @param {string} text - Message text
98
+ * @param {object} opts - Additional options (reply_broadcast, etc.)
99
+ */
100
+ const postMessageInThread = (channel, threadTs, text, opts = {}) => {
101
+ return postMessage(channel, text, { ...opts, thread_ts: threadTs });
102
+ };
103
+
104
+ /**
105
+ * Add a reaction emoji to a message
106
+ * @param {string} channel - Channel ID
107
+ * @param {string} timestamp - Message timestamp
108
+ * @param {string} emoji - Emoji name (without colons, e.g., "white_check_mark")
109
+ */
110
+ const addReaction = (channel, timestamp, emoji) => {
111
+ // Remove colons if user included them
112
+ const cleanEmoji = String(emoji || "").replace(/^:|:$/g, "");
113
+ return call("reactions.add", {
114
+ channel,
115
+ timestamp,
116
+ name: cleanEmoji,
117
+ });
118
+ };
119
+
120
+ /**
121
+ * Remove a reaction emoji from a message
122
+ * @param {string} channel - Channel ID
123
+ * @param {string} timestamp - Message timestamp
124
+ * @param {string} emoji - Emoji name (without colons)
125
+ */
126
+ const removeReaction = (channel, timestamp, emoji) => {
127
+ const cleanEmoji = String(emoji || "").replace(/^:|:$/g, "");
128
+ return call("reactions.remove", {
129
+ channel,
130
+ timestamp,
131
+ name: cleanEmoji,
132
+ });
133
+ };
134
+
135
+ /**
136
+ * Upload a file to Slack using the 3-step external upload flow
137
+ * @param {string|string[]} channels - Channel ID(s) to share file in
138
+ * @param {Buffer|Stream|string} fileContent - File content (Buffer, Stream, or file path)
139
+ * @param {object} opts - Options
140
+ * @param {string} opts.filename - Filename
141
+ * @param {string} opts.title - File title
142
+ * @param {string} opts.initial_comment - Comment to add with file
143
+ * @param {string} opts.thread_ts - Thread timestamp (upload to thread)
144
+ * @param {string} opts.contentType - MIME type
145
+ * @returns {Promise<object>} Upload response with file info
146
+ */
147
+ const uploadFile = async (channels, fileContent, opts = {}) => {
148
+ const filename = opts.filename || "file";
149
+ const buffer = await toBuffer(fileContent);
150
+ const filesize = buffer.length;
151
+
152
+ // Step 1: Get upload URL
153
+ const uploadInfo = await call("files.getUploadURLExternal", {
154
+ filename,
155
+ length: filesize,
156
+ });
157
+
158
+ const { upload_url, file_id } = uploadInfo;
159
+
160
+ // Step 2: Upload file to the external URL (raw POST, no auth)
161
+ const uploadRes = await fetch(upload_url, {
162
+ method: "POST",
163
+ headers: {
164
+ "Content-Type": opts.contentType || "application/octet-stream",
165
+ },
166
+ body: buffer,
167
+ });
168
+
169
+ if (!uploadRes.ok) {
170
+ throw new Error(`File upload to external URL failed: HTTP ${uploadRes.status}`);
171
+ }
172
+
173
+ // Step 3: Complete the upload and share to channel(s)
174
+ const completePayload = {
175
+ files: [
176
+ {
177
+ id: file_id,
178
+ title: opts.title || filename,
179
+ },
180
+ ],
181
+ };
182
+
183
+ // Handle single channel vs multiple channels
184
+ if (channels) {
185
+ if (Array.isArray(channels)) {
186
+ completePayload.channel_id = channels[0]; // Primary channel
187
+ if (channels.length > 1) {
188
+ throw new Error("Multi-channel upload not supported with external upload flow. Use channel_id for one channel.");
189
+ }
190
+ } else {
191
+ completePayload.channel_id = channels;
192
+ }
193
+ }
194
+
195
+ if (opts.initial_comment) {
196
+ completePayload.initial_comment = opts.initial_comment;
197
+ }
198
+
199
+ if (opts.thread_ts) {
200
+ completePayload.thread_ts = opts.thread_ts;
201
+ }
202
+
203
+ return call("files.completeUploadExternal", completePayload);
204
+ };
205
+
206
+ /**
207
+ * Upload text as a code snippet with syntax highlighting
208
+ * @param {string|string[]} channels - Channel ID(s)
209
+ * @param {string} content - Text content
210
+ * @param {object} opts - Options
211
+ * @param {string} opts.filename - Filename (affects syntax highlighting, e.g., "code.js")
212
+ * @param {string} opts.title - Snippet title
213
+ * @param {string} opts.filetype - File type for syntax highlighting (e.g., "javascript")
214
+ * @param {string} opts.initial_comment - Comment
215
+ * @param {string} opts.thread_ts - Thread timestamp
216
+ */
217
+ const uploadTextSnippet = (channels, content, opts = {}) => {
218
+ const buffer = Buffer.from(String(content || ""), "utf8");
219
+
220
+ // Detect language from filename if provided
221
+ let filename = opts.filename || "snippet.txt";
222
+ if (opts.filetype) {
223
+ const ext = opts.filetype.replace(/^\./, "");
224
+ if (!filename.includes(".")) {
225
+ filename = `snippet.${ext}`;
226
+ }
227
+ }
228
+
229
+ return uploadFile(channels, buffer, {
230
+ ...opts,
231
+ filename,
232
+ contentType: "text/plain",
233
+ });
234
+ };
31
235
 
32
236
  return {
33
237
  authTest,
34
238
  postMessage,
239
+ postMessageInThread,
240
+ addReaction,
241
+ removeReaction,
242
+ uploadFile,
243
+ uploadTextSnippet,
35
244
  };
36
245
  };
37
246
 
@@ -36,8 +36,14 @@ const getPairedIds = (channel) => {
36
36
  const formatDiscordMessage = (message) =>
37
37
  String(message || "").replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "**$1**");
38
38
 
39
+ /**
40
+ * Track thread state for Slack notifications
41
+ * Key: userId, Value: { threadTs, lastEvent }
42
+ */
43
+ const slackThreads = new Map();
44
+
39
45
  const createWatchdogNotifier = ({ telegramApi, discordApi, slackApi }) => {
40
- const notify = async (message) => {
46
+ const notify = async (message, opts = {}) => {
41
47
  const summary = {
42
48
  telegram: { sent: 0, failed: 0, skipped: false, targets: 0 },
43
49
  discord: { sent: 0, failed: 0, skipped: false, targets: 0 },
@@ -78,14 +84,58 @@ const createWatchdogNotifier = ({ telegramApi, discordApi, slackApi }) => {
78
84
  }
79
85
  }
80
86
 
87
+ // Enhanced Slack notifications with threading and reactions
81
88
  const slackTargets = getPairedIds("slack");
82
89
  summary.slack.targets = slackTargets.length;
83
90
  if (!slackApi?.postMessage || !process.env.SLACK_BOT_TOKEN || slackTargets.length === 0) {
84
91
  summary.slack.skipped = true;
85
92
  } else {
93
+ const eventType = opts.eventType || "info"; // crash, recovery, health, info
94
+
86
95
  for (const userId of slackTargets) {
87
96
  try {
88
- await slackApi.postMessage(userId, String(message || ""));
97
+ let threadTs = null;
98
+ let shouldCreateNewThread = true;
99
+
100
+ // Check if we have an active thread for this user
101
+ const existingThread = slackThreads.get(userId);
102
+ if (existingThread && existingThread.lastEvent === "crash" && eventType === "recovery") {
103
+ // Recovery message goes in the crash thread
104
+ threadTs = existingThread.threadTs;
105
+ shouldCreateNewThread = false;
106
+ }
107
+
108
+ // Send message (in thread if continuing conversation)
109
+ const result = await slackApi.postMessage(userId, String(message || ""), {
110
+ thread_ts: threadTs,
111
+ mrkdwn: true,
112
+ });
113
+
114
+ // Store thread for future related messages
115
+ if (shouldCreateNewThread && result.ts) {
116
+ slackThreads.set(userId, {
117
+ threadTs: result.ts,
118
+ lastEvent: eventType,
119
+ });
120
+ }
121
+
122
+ // Add reactions based on event type
123
+ // Use result.channel (the actual conversation/DM ID) instead of userId
124
+ if (result.ts && result.channel && slackApi.addReaction) {
125
+ try {
126
+ if (eventType === "crash") {
127
+ await slackApi.addReaction(result.channel, result.ts, "x");
128
+ } else if (eventType === "recovery") {
129
+ await slackApi.addReaction(result.channel, result.ts, "white_check_mark");
130
+ } else if (eventType === "health") {
131
+ await slackApi.addReaction(result.channel, result.ts, "heart");
132
+ }
133
+ } catch (reactionErr) {
134
+ // Reactions are nice-to-have, don't fail the whole notification
135
+ console.error(`[watchdog] slack reaction failed for ${userId}: ${reactionErr.message}`);
136
+ }
137
+ }
138
+
89
139
  summary.slack.sent += 1;
90
140
  } catch (err) {
91
141
  summary.slack.failed += 1;
@@ -184,12 +184,12 @@ const createWatchdog = ({
184
184
  }
185
185
  };
186
186
 
187
- const notify = async (message, correlationId = "") => {
187
+ const notify = async (message, correlationId = "", eventType = "info") => {
188
188
  if (state.notificationsDisabled) {
189
189
  return { ok: false, skipped: true, reason: "notifications_disabled" };
190
190
  }
191
191
  if (!notifier?.notify) return { ok: false, reason: "notifier_unavailable" };
192
- const result = await notifier.notify(message);
192
+ const result = await notifier.notify(message, { eventType });
193
193
  logEvent(
194
194
  "notification",
195
195
  "watchdog",
@@ -204,9 +204,10 @@ const createWatchdog = ({
204
204
  notificationKey,
205
205
  message,
206
206
  correlationId = "",
207
+ eventType = "info",
207
208
  ) => {
208
209
  const key = String(notificationKey || "").trim();
209
- if (!key) return notify(message, correlationId);
210
+ if (!key) return notify(message, correlationId, eventType);
210
211
  if (sentIncidentNotifications.has(key)) {
211
212
  return {
212
213
  ok: false,
@@ -214,7 +215,7 @@ const createWatchdog = ({
214
215
  reason: "incident_notification_already_sent",
215
216
  };
216
217
  }
217
- const result = await notify(message, correlationId);
218
+ const result = await notify(message, correlationId, eventType);
218
219
  if (result?.ok || result?.skipped) {
219
220
  sentIncidentNotifications.add(key);
220
221
  }
@@ -273,6 +274,7 @@ const createWatchdog = ({
273
274
  ...(attempts > 0 ? [`Attempt count: ${attempts}`] : []),
274
275
  ].join("\n"),
275
276
  correlationId,
277
+ ok && verifiedHealthy ? "recovery" : "crash",
276
278
  );
277
279
  };
278
280
 
@@ -468,6 +470,7 @@ const createWatchdog = ({
468
470
  withViewLogsSuffix("Auto-repair paused until manual action."),
469
471
  ].join("\n"),
470
472
  correlationId,
473
+ "crash",
471
474
  );
472
475
  }
473
476
  return { ok: false, result };
@@ -543,6 +546,7 @@ const createWatchdog = ({
543
546
  withViewLogsSuffix("🟢 Gateway healthy again"),
544
547
  ].join("\n"),
545
548
  correlationId,
549
+ "recovery",
546
550
  );
547
551
  }
548
552
  state.pendingRecoveryNoticeSource = "";
@@ -766,6 +770,7 @@ const createWatchdog = ({
766
770
  : ["Auto-restart paused; manual action required."]),
767
771
  ].join("\n"),
768
772
  correlationId,
773
+ "crash",
769
774
  );
770
775
  if (state.autoRepair) {
771
776
  void runRepair({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.8.3-beta.0",
3
+ "version": "0.8.3-beta.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -19,11 +19,13 @@
19
19
  },
20
20
  "files": [
21
21
  "bin/",
22
- "lib/"
22
+ "lib/",
23
+ "patches/"
23
24
  ],
24
25
  "scripts": {
25
26
  "start": "node bin/alphaclaw.js start",
26
27
  "build:ui": "node scripts/build-ui.mjs",
28
+ "postinstall": "patch-package",
27
29
  "test": "vitest run",
28
30
  "test:watch": "vitest",
29
31
  "test:watchdog": "vitest run tests/server/watchdog.test.js tests/server/watchdog-db.test.js tests/server/routes-watchdog.test.js",
@@ -33,13 +35,14 @@
33
35
  "dependencies": {
34
36
  "express": "^4.21.0",
35
37
  "http-proxy": "^1.18.1",
36
- "openclaw": "2026.3.13",
38
+ "openclaw": "^2026.3.28",
39
+ "patch-package": "^8.0.1",
37
40
  "ws": "^8.19.0"
38
41
  },
39
42
  "devDependencies": {
43
+ "@vitest/coverage-v8": "^4.0.18",
40
44
  "@xterm/addon-fit": "^0.10.0",
41
45
  "@xterm/xterm": "^5.5.0",
42
- "@vitest/coverage-v8": "^4.0.18",
43
46
  "chart.js": "^4.5.1",
44
47
  "esbuild": "^0.25.9",
45
48
  "htm": "^3.1.1",
@@ -51,6 +54,6 @@
51
54
  "wouter-preact": "^3.7.1"
52
55
  },
53
56
  "engines": {
54
- "node": ">=22.12.0"
57
+ "node": ">=22.14.0"
55
58
  }
56
59
  }
@@ -0,0 +1,13 @@
1
+ diff --git a/node_modules/openclaw/dist/gateway-cli-DlnlX7IW.js b/node_modules/openclaw/dist/gateway-cli-DlnlX7IW.js
2
+ index ca48b932..c12478c4 100644
3
+ --- a/node_modules/openclaw/dist/gateway-cli-DlnlX7IW.js
4
+ +++ b/node_modules/openclaw/dist/gateway-cli-DlnlX7IW.js
5
+ @@ -25935,7 +25935,7 @@ function attachGatewayWsMessageHandler(params) {
6
+ close(1008, truncateCloseReason(authMessage));
7
+ };
8
+ const clearUnboundScopes = () => {
9
+ - if (scopes.length > 0) {
10
+ + if (scopes.length > 0 && !sharedAuthOk) {
11
+ scopes = [];
12
+ connectParams.scopes = scopes;
13
+ }