dsh-desktop-kit 2.2.1 → 2.7.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +113 -27
  3. package/client.js +3121 -32
  4. package/docs/AGENT-HARNESS.md +144 -0
  5. package/docs/ARCHITECTURE.md +38 -7
  6. package/docs/COMPATIBILITY.md +4 -2
  7. package/docs/DEVELOPMENT.md +56 -0
  8. package/docs/INSTALL.md +20 -11
  9. package/docs/LAPTOP-INSTALL.md +96 -0
  10. package/docs/ROADMAP-3.0.md +353 -0
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/SUBAGENTS.md +25 -0
  13. package/docs/TEST-0.2.2.md +5 -5
  14. package/docs/TEST-2.3.0.md +49 -0
  15. package/docs/TEST-2.7.2.md +46 -0
  16. package/docs/TOKENS.md +2 -2
  17. package/docs/TROUBLESHOOTING.md +27 -0
  18. package/docs/UI-QA.md +28 -0
  19. package/docs/images/peak-toast-peak.png +0 -0
  20. package/docs/images/peak-toast-soon.png +0 -0
  21. package/docs/preview/peak-toast.html +155 -0
  22. package/index.js +18 -1
  23. package/package.json +26 -6
  24. package/plugins/dsh-agent-extensions/README.md +99 -0
  25. package/plugins/dsh-agent-extensions/index.js +167 -0
  26. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  27. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  28. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  29. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  30. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  31. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  32. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  33. package/plugins/dsh-agent-extensions/package.json +9 -0
  34. package/plugins/dsh-browser-qa/README.md +221 -0
  35. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  36. package/plugins/dsh-browser-qa/index.js +165 -0
  37. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  38. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  39. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  40. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  41. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  42. package/plugins/dsh-browser-qa/package.json +31 -0
  43. package/plugins/dsh-checkpoints/README.md +22 -0
  44. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  45. package/plugins/dsh-checkpoints/index.js +80 -0
  46. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  47. package/plugins/dsh-checkpoints/package.json +25 -0
  48. package/plugins/dsh-github-review/README.md +131 -0
  49. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  50. package/plugins/dsh-github-review/index.js +536 -0
  51. package/plugins/dsh-github-review/lib/gh.js +494 -0
  52. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  53. package/plugins/dsh-github-review/package.json +28 -0
  54. package/plugins/dsh-guardrails/README.md +264 -0
  55. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  56. package/plugins/dsh-guardrails/index.js +389 -0
  57. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  58. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  59. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  60. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  61. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  62. package/plugins/dsh-guardrails/package.json +26 -0
  63. package/plugins/dsh-mcp-manager/README.md +78 -0
  64. package/plugins/dsh-mcp-manager/client.js +373 -0
  65. package/plugins/dsh-mcp-manager/index.js +159 -0
  66. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  67. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  68. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  69. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  70. package/plugins/dsh-mcp-manager/package.json +18 -0
  71. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  72. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  73. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  74. package/plugins/dsh-memory-bridge/README.md +161 -0
  75. package/plugins/dsh-memory-bridge/client.js +2264 -0
  76. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  77. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  78. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  79. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  80. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  81. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  82. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  83. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  84. package/plugins/dsh-memory-bridge/index.js +378 -0
  85. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  86. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  87. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  88. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  89. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  90. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  91. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  92. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  93. package/plugins/dsh-memory-bridge/package.json +67 -0
  94. package/plugins/dsh-peak-guard/README.md +16 -3
  95. package/plugins/dsh-peak-guard/client.js +223 -8
  96. package/plugins/dsh-peak-guard/package.json +1 -1
  97. package/plugins/dsh-plan-mode/README.md +24 -0
  98. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  99. package/plugins/dsh-plan-mode/index.js +103 -0
  100. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  101. package/plugins/dsh-plan-mode/package.json +18 -0
  102. package/plugins/dsh-profile-transfer/client.js +277 -17
  103. package/plugins/dsh-profile-transfer/index.js +10 -0
  104. package/plugins/dsh-profile-transfer/package.json +3 -1
  105. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  106. package/plugins/dsh-project-memory/index.js +92 -2
  107. package/plugins/dsh-project-memory/package.json +1 -1
  108. package/plugins/dsh-repo-map/README.md +26 -0
  109. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  110. package/plugins/dsh-repo-map/index.js +99 -0
  111. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  112. package/plugins/dsh-repo-map/package.json +25 -0
  113. package/plugins/dsh-skill-autopilot/index.js +135 -11
  114. package/skills/delivery-gate/SKILL.md +31 -0
  115. package/skills/git-and-ci/SKILL.md +5 -1
  116. package/skills/manifest.json +31 -6
  117. package/skills/requirements-and-planning/SKILL.md +3 -0
  118. package/skills/research-first/SKILL.md +35 -0
  119. package/skills/research-first/references/query-patterns.md +20 -0
  120. package/skills/subagent-playbook/SKILL.md +36 -0
  121. package/skills/subagent-playbook/references/prompts.md +39 -0
  122. package/skills/ui-craft/SKILL.md +43 -0
  123. package/skills/visual-qa/SKILL.md +29 -0
  124. package/skills/visual-qa/references/commands.md +32 -0
  125. package/skills-baseline.json +219 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * dsh-browser-qa — headless-Chrome quality checks for DSH agents.
3
+ *
4
+ * One tool, three actions:
5
+ * status — detect Chrome/Edge and report its path/version;
6
+ * shot — open a URL or local HTML file, set a viewport, save a PNG;
7
+ * audit — at responsive widths measure horizontal overflow, list the
8
+ * overflowing elements and console errors, optionally save PNGs.
9
+ *
10
+ * Everything is Node-built-ins only: the DevTools protocol is spoken over the
11
+ * WebSocket client built into Node 22+/24, so there is no Playwright dependency.
12
+ */
13
+ import { auditPages, captureShot, statusReport } from "./lib/pages.js";
14
+ import { createUiGate } from "./lib/ui-gate.js";
15
+ import { errorMessage, normalizeViewport, parseTimeout, parseWidths } from "./lib/qa.js";
16
+
17
+ export const inject = ["tools"];
18
+
19
+ const TOOL_NAME = "browser_qa";
20
+ const TOOL_TIMEOUT_MS = 240_000;
21
+ const MAX_RENDER_CHARS = 24_000;
22
+
23
+ function sessionCwd(exec) {
24
+ const cwd = exec?.agent?.session?.header?.cwd;
25
+ return typeof cwd === "string" && cwd.length > 0 ? cwd : process.cwd();
26
+ }
27
+
28
+ function sessionSignal(exec) {
29
+ const signal = exec?.signal ?? exec?.abortSignal;
30
+ return signal && typeof signal.addEventListener === "function" ? signal : undefined;
31
+ }
32
+
33
+ function failure(prefix, error) {
34
+ const message = errorMessage(error);
35
+ const result = { ok: false, error: message, text: message.startsWith(prefix) ? message : `${prefix}: ${message}` };
36
+ if (error && typeof error.code === "string") result.code = error.code;
37
+ if (error && Array.isArray(error.candidates)) result.candidates = error.candidates;
38
+ return result;
39
+ }
40
+
41
+ function missingUrl(action) {
42
+ return {
43
+ ok: false,
44
+ error: "url is required",
45
+ text: `browser_qa ${action}: нужен параметр url — http(s)-адрес, file:// или путь к локальному HTML-файлу`,
46
+ };
47
+ }
48
+
49
+ function makeToolDefinition() {
50
+ return {
51
+ name: TOOL_NAME,
52
+ description:
53
+ "Headless-Chrome quality checks without Playwright. status: detect Chrome and its version. shot: open an http(s) URL or local HTML file, set the viewport, save a PNG and return its path/size. audit: load the page at responsive widths (default 390/768/1440) and report horizontal overflow (documentElement.scrollWidth vs clientWidth), the overflowing elements, console errors, markup/accessibility issues (missing alt, unlabeled fields, nameless links/buttons, duplicate ids, heading order, lang/title/viewport, positive tabindex), and optionally one PNG per width (pass outDir). Uses the Chrome DevTools protocol over a temporary profile; no external network calls beyond the page itself.",
54
+ parameters: {
55
+ type: "object",
56
+ additionalProperties: false,
57
+ properties: {
58
+ action: {
59
+ type: "string",
60
+ enum: ["shot", "audit", "status"],
61
+ description: "shot: one viewport PNG | audit: overflow/console report at responsive widths | status: Chrome path/version",
62
+ },
63
+ url: {
64
+ type: "string",
65
+ description: "http(s) URL, file:// URL or local HTML file path (relative paths resolve from the Session cwd).",
66
+ },
67
+ width: { type: "integer", description: "Viewport width for shot, in CSS px (default 1440, range 200..3840)." },
68
+ height: { type: "integer", description: "Viewport height, in CSS px (default 900, range 200..2160)." },
69
+ widths: {
70
+ type: "array",
71
+ items: { type: "integer" },
72
+ description: "Audit widths in CSS px (default [390, 768, 1440], at most 6 values).",
73
+ },
74
+ outDir: {
75
+ type: "string",
76
+ description:
77
+ "Directory for PNGs. shot default: <cwd>/.dsh/browser-qa. audit saves one PNG per width only when outDir is given.",
78
+ },
79
+ fullPage: { type: "boolean", description: "Capture the full page instead of the viewport (height capped at 16384 px)." },
80
+ timeoutMs: { type: "integer", description: "Page load timeout per width in ms (default 30000, range 3000..120000)." },
81
+ },
82
+ required: ["action"],
83
+ },
84
+ timeoutMs: TOOL_TIMEOUT_MS,
85
+ output: {
86
+ schema: { type: "object", additionalProperties: true },
87
+ render(_args, value) {
88
+ const text =
89
+ value !== null && typeof value === "object" && typeof value.text === "string"
90
+ ? value.text
91
+ : JSON.stringify(value);
92
+ return [{ type: "text", text: text.length <= MAX_RENDER_CHARS ? text : `${text.slice(0, MAX_RENDER_CHARS)}\n…` }];
93
+ },
94
+ },
95
+ async execute(args, exec) {
96
+ const action = String(args?.action ?? "").trim().toLowerCase();
97
+ const cwd = sessionCwd(exec);
98
+ const signal = sessionSignal(exec);
99
+ try {
100
+ if (action === "status") {
101
+ const status = await statusReport({ cwd });
102
+ if (status.ok !== true) {
103
+ status.error = status.chrome?.probeError ?? "Chrome is not available";
104
+ }
105
+ return status;
106
+ }
107
+ if (action === "shot") {
108
+ if (typeof args?.url !== "string" || args.url.trim().length === 0) return missingUrl("shot");
109
+ const viewport = normalizeViewport(args);
110
+ return await captureShot({
111
+ target: args.url,
112
+ width: viewport.width,
113
+ height: viewport.height,
114
+ fullPage: args?.fullPage === true,
115
+ outDir: args?.outDir,
116
+ timeoutMs: parseTimeout(args?.timeoutMs),
117
+ cwd,
118
+ signal,
119
+ });
120
+ }
121
+ if (action === "audit") {
122
+ if (typeof args?.url !== "string" || args.url.trim().length === 0) return missingUrl("audit");
123
+ const widths = parseWidths(args?.widths);
124
+ const viewport = normalizeViewport(args);
125
+ const hasOutDir = typeof args?.outDir === "string" && args.outDir.trim().length > 0;
126
+ return await auditPages({
127
+ target: args.url,
128
+ widths,
129
+ height: viewport.height,
130
+ fullPage: args?.fullPage === true,
131
+ outDir: hasOutDir ? args.outDir : null,
132
+ screenshots: hasOutDir,
133
+ timeoutMs: parseTimeout(args?.timeoutMs),
134
+ cwd,
135
+ signal,
136
+ });
137
+ }
138
+ return {
139
+ ok: false,
140
+ error: `unknown action: ${action || "(empty)"}`,
141
+ text: `browser_qa: неизвестное действие «${action || "(пусто)"}». Доступно: shot, audit, status.`,
142
+ };
143
+ } catch (error) {
144
+ return failure("browser_qa", error);
145
+ }
146
+ },
147
+ };
148
+ }
149
+
150
+ export function apply(ctx) {
151
+ const tool = makeToolDefinition();
152
+ const register = () => ctx.tools.register(tool);
153
+ if (typeof ctx.effect === "function") {
154
+ ctx.effect(register, "dsh-browser-qa: tool");
155
+ } else {
156
+ register();
157
+ }
158
+ try {
159
+ const gate = createUiGate(ctx);
160
+ if (typeof ctx.effect === "function") ctx.effect(() => () => gate.dispose(), "dsh-browser-qa: ui gate");
161
+ } catch (error) {
162
+ ctx.logger?.warn?.(`[browser-qa] ui gate skipped: ${errorMessage(error)}`);
163
+ }
164
+ ctx.logger?.info?.("[browser-qa] tool ready");
165
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Minimal Chrome DevTools Protocol client.
3
+ *
4
+ * Uses the WebSocket client built into Node.js (22+/24) so the plugin has no
5
+ * npm dependencies. Only request/response plus named events are implemented —
6
+ * enough for Page, Runtime, Log and Emulation domains used by browser_qa.
7
+ */
8
+ export class Cdp {
9
+ constructor(webSocketUrl, options = {}) {
10
+ if (typeof webSocketUrl !== "string" || webSocketUrl.length === 0) {
11
+ throw new Error("CDP websocket URL is empty");
12
+ }
13
+ if (typeof WebSocket !== "function") {
14
+ throw new Error("This Node.js runtime has no built-in WebSocket (Node 22+ is required)");
15
+ }
16
+ this.webSocketUrl = webSocketUrl;
17
+ this.ws = null;
18
+ this.nextId = 0;
19
+ this.pending = new Map();
20
+ this.listeners = new Map();
21
+ this.closeWaiters = new Set();
22
+ this.closed = false;
23
+ this.sendTimeoutMs = options.sendTimeoutMs ?? 15_000;
24
+ }
25
+
26
+ async connect(timeoutMs = 10_000) {
27
+ const ws = new WebSocket(this.webSocketUrl);
28
+ this.ws = ws;
29
+ try {
30
+ await new Promise((resolve, reject) => {
31
+ let settled = false;
32
+ const timer = setTimeout(() => {
33
+ if (settled) return;
34
+ settled = true;
35
+ cleanup();
36
+ reject(new Error("CDP websocket connect timeout"));
37
+ }, timeoutMs);
38
+ if (typeof timer.unref === "function") timer.unref();
39
+ const cleanup = () => {
40
+ clearTimeout(timer);
41
+ ws.removeEventListener("open", onOpen);
42
+ ws.removeEventListener("error", onError);
43
+ ws.removeEventListener("close", onClose);
44
+ };
45
+ const onOpen = () => {
46
+ if (settled) return;
47
+ settled = true;
48
+ cleanup();
49
+ resolve();
50
+ };
51
+ const onError = () => {
52
+ if (settled) return;
53
+ settled = true;
54
+ cleanup();
55
+ reject(new Error("CDP websocket connection error"));
56
+ };
57
+ const onClose = () => {
58
+ if (settled) return;
59
+ settled = true;
60
+ cleanup();
61
+ reject(new Error("CDP websocket closed before it opened"));
62
+ };
63
+ ws.addEventListener("open", onOpen);
64
+ ws.addEventListener("error", onError);
65
+ ws.addEventListener("close", onClose);
66
+ });
67
+ } catch (error) {
68
+ try {
69
+ ws.close();
70
+ } catch {
71
+ /* already closed */
72
+ }
73
+ this.ws = null;
74
+ this.closed = true;
75
+ throw error;
76
+ }
77
+ this.closed = false;
78
+ ws.addEventListener("message", (event) => this.handleMessage(event.data));
79
+ ws.addEventListener("close", () => this.handleClose());
80
+ ws.addEventListener("error", () => {
81
+ /* a close event follows; pending calls will be rejected there */
82
+ });
83
+ return this;
84
+ }
85
+
86
+ isClosed() {
87
+ return this.closed || this.ws === null;
88
+ }
89
+
90
+ handleMessage(raw) {
91
+ let text;
92
+ if (typeof raw === "string") text = raw;
93
+ else if (raw instanceof ArrayBuffer) text = Buffer.from(raw).toString("utf8");
94
+ else if (ArrayBuffer.isView(raw)) text = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString("utf8");
95
+ else text = String(raw ?? "");
96
+ let message;
97
+ try {
98
+ message = JSON.parse(text);
99
+ } catch {
100
+ return;
101
+ }
102
+ if (message === null || typeof message !== "object") return;
103
+ if (message.id !== undefined) {
104
+ const pending = this.pending.get(message.id);
105
+ if (pending === undefined) return;
106
+ this.pending.delete(message.id);
107
+ clearTimeout(pending.timer);
108
+ if (message.error) pending.reject(new Error(message.error.message || "CDP request failed"));
109
+ else pending.resolve(message.result);
110
+ return;
111
+ }
112
+ if (typeof message.method !== "string") return;
113
+ const listeners = this.listeners.get(message.method);
114
+ if (!listeners || listeners.size === 0) return;
115
+ for (const listener of [...listeners]) {
116
+ try {
117
+ listener(message.params ?? {});
118
+ } catch {
119
+ /* a listener must never break the protocol loop */
120
+ }
121
+ }
122
+ }
123
+
124
+ handleClose() {
125
+ if (this.closed) return;
126
+ this.closed = true;
127
+ this.ws = null;
128
+ const error = new Error("CDP connection closed");
129
+ for (const pending of this.pending.values()) {
130
+ clearTimeout(pending.timer);
131
+ pending.reject(error);
132
+ }
133
+ this.pending.clear();
134
+ for (const fail of [...this.closeWaiters]) {
135
+ try {
136
+ fail(error);
137
+ } catch {
138
+ /* ignore */
139
+ }
140
+ }
141
+ this.closeWaiters.clear();
142
+ }
143
+
144
+ send(method, params = {}, timeoutMs = this.sendTimeoutMs) {
145
+ if (this.isClosed()) return Promise.reject(new Error(`CDP connection is closed (${method})`));
146
+ const id = (this.nextId += 1);
147
+ return new Promise((resolve, reject) => {
148
+ const timer = setTimeout(() => {
149
+ this.pending.delete(id);
150
+ reject(new Error(`CDP timeout: ${method} (${timeoutMs} ms)`));
151
+ }, timeoutMs);
152
+ if (typeof timer.unref === "function") timer.unref();
153
+ this.pending.set(id, { resolve, reject, timer, method });
154
+ try {
155
+ this.ws.send(JSON.stringify({ id, method, params }));
156
+ } catch (error) {
157
+ clearTimeout(timer);
158
+ this.pending.delete(id);
159
+ reject(error instanceof Error ? error : new Error(String(error)));
160
+ }
161
+ });
162
+ }
163
+
164
+ /** Subscribe to a CDP event; returns an unsubscribe function. */
165
+ on(method, listener) {
166
+ if (typeof listener !== "function") throw new Error("CDP listener must be a function");
167
+ if (!this.listeners.has(method)) this.listeners.set(method, new Set());
168
+ this.listeners.get(method).add(listener);
169
+ return () => {
170
+ const listeners = this.listeners.get(method);
171
+ if (!listeners) return;
172
+ listeners.delete(listener);
173
+ if (listeners.size === 0) this.listeners.delete(method);
174
+ };
175
+ }
176
+
177
+ /** Resolve on the next event, reject on timeout or connection close. */
178
+ once(method, timeoutMs = 15_000) {
179
+ return new Promise((resolve, reject) => {
180
+ let settled = false;
181
+ let off = null;
182
+ const finish = (callback, value) => {
183
+ if (settled) return;
184
+ settled = true;
185
+ clearTimeout(timer);
186
+ if (off) off();
187
+ this.closeWaiters.delete(fail);
188
+ callback(value);
189
+ };
190
+ const fail = (error) => finish(reject, error);
191
+ const timer = setTimeout(() => finish(reject, new Error(`CDP event timeout: ${method} (${timeoutMs} ms)`)), timeoutMs);
192
+ if (typeof timer.unref === "function") timer.unref();
193
+ this.closeWaiters.add(fail);
194
+ off = this.on(method, (params) => finish(resolve, params));
195
+ });
196
+ }
197
+
198
+ close() {
199
+ if (this.closed) return;
200
+ this.closed = true;
201
+ const error = new Error("CDP client closed");
202
+ for (const pending of this.pending.values()) {
203
+ clearTimeout(pending.timer);
204
+ pending.reject(error);
205
+ }
206
+ this.pending.clear();
207
+ for (const fail of [...this.closeWaiters]) {
208
+ try {
209
+ fail(error);
210
+ } catch {
211
+ /* ignore */
212
+ }
213
+ }
214
+ this.closeWaiters.clear();
215
+ this.listeners.clear();
216
+ const ws = this.ws;
217
+ this.ws = null;
218
+ try {
219
+ ws?.close();
220
+ } catch {
221
+ /* already closed */
222
+ }
223
+ }
224
+ }